package public import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "time" sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/auth/ipguard" authmodel "git.sendico.io/tech/sxchnge/product/auth/model" customer "git.sendico.io/tech/sxchnge/product/customers/model" session "git.sendico.io/tech/sxchnge/product/sessions/model" factor "git.sendico.io/tech/sxchnge/product/twofactor/model" verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" verification "git.sendico.io/tech/sxchnge/product/verification/store" merrors "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/http/response" "github.com/go-chi/jwtauth/v5" "go.uber.org/zap" ) type signupRequest struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` Locale string `json:"locale"` Key string `json:"idempotencyKey"` } type challengeRequest struct { ChallengeID string `json:"challengeId"` PendingToken string `json:"pendingToken"` Code string `json:"code"` Key string `json:"idempotencyKey"` Session session.SessionIdentifier `json:"sessionIdentifier"` } type challengeResponse struct { ChallengeID string `json:"challengeId"` Channel factor.Channel `json:"channel"` UserID string `json:"userId"` PendingToken sresponse.TokenData `json:"pendingToken"` Target string `json:"target"` ExpiresAt time.Time `json:"expiresAt"` ResendAt time.Time `json:"resendAt"` } func decodeFlow(r *http.Request, dst any) error { d := json.NewDecoder(io.LimitReader(r.Body, 8193)) d.DisallowUnknownFields() if err := d.Decode(dst); err != nil { return merrors.InvalidArgument("invalid request") } var extra any if err := d.Decode(&extra); err != io.EOF { return merrors.InvalidArgument("invalid request") } return nil } func requestIP(r *http.Request) string { if ip := ipguard.ClientIP(r); ip != nil { return ip.String() } return "unresolved" } func (pr *PublicRouter) flowError(err error) http.HandlerFunc { if details, ok := verification.Throttle(err); ok { return pr.loginRateLimited(details.RetryAfter) } switch { case errors.Is(err, merrors.ErrFailedPrecondition): return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.") case errors.Is(err, verification.ErrTokenExpired): return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email") case errors.Is(err, verification.ErrTokenAttemptsExceeded): return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code") case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed): return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used") case errors.Is(err, merrors.ErrDataConflict): return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered") case errors.Is(err, merrors.ErrInvalidArg): return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields") case errors.Is(err, merrors.ErrUnauthorized): return response.Unauthorized(pr.logger, pr.service, "credentials required") default: // Do not include downstream errors: they may contain transport/request details. pr.logger.Warn("Authentication flow dependency failed") return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`)) } } } func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc { var req signupRequest if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } if req.Key == "" || len(req.Key) > 128 { return pr.flowError(merrors.InvalidArgument("idempotency key required")) } a := &customer.Account{Password: req.Password} a.Name = req.Name a.Login = req.Email a.Locale = req.Locale a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key) if err != nil { return pr.flowError(err) } pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex())) return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()}) } func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc { var req struct { Email string `json:"email"` Key string `json:"idempotencyKey"` } if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } if req.Key == "" || len(req.Key) > 128 { return pr.flowError(merrors.InvalidArgument("idempotency key required")) } if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil { return pr.flowError(err) } return response.Accepted(pr.logger, map[string]bool{"accepted": true}) } func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc { var req struct { Token string `json:"token"` } if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } if err := pr.flow.Activate(r.Context(), req.Token); err != nil { return pr.flowError(err) } pr.logger.Info("Account activation completed") return response.Accepted(pr.logger, map[string]bool{"verified": true}) } func challengeScope(raw string) string { h := sha256.Sum256([]byte(raw)) return hex.EncodeToString(h[:]) } func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc { if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 { return pr.flowError(merrors.InvalidArgument("session identifier required")) } claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes) claims["clientId"] = sessionID.ClientID claims["deviceId"] = sessionID.DeviceID // Independent challenges, even when password requests arrive within one second. claims["nonce"] = randomKey() ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey) _, raw, err := ja.Encode(claims) if err != nil { return pr.flowError(err) } token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)} return pr.queueLogin(r, a, token, key, "") } func randomKey() string { return rand.Text() } func loginScope(raw string) factor.Scope { return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)} } func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc { if pr.factors == nil { return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable")) } req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)} var challenge factor.Challenge var err error if id == "" { challenge, err = pr.factors.Begin(r.Context(), req) } else { challenge, err = pr.factors.Resend(r.Context(), req, id) } if err != nil { return pr.flowError(err) } return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt}) } func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) { if len(raw) > 8192 { return nil, nil, merrors.Unauthorized("invalid pending token") } ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey) t, err := jwtauth.VerifyToken(ja, raw) if err != nil { return nil, nil, merrors.Unauthorized("invalid pending token") } _, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil)) if err != nil { return nil, nil, err } token, err := authmodel.Claims2Token(claims) if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" { return nil, nil, merrors.Unauthorized("pending login required") } a := &customer.Account{} if err = pr.db.Get(ctx, token.AccountRef, a); err != nil { return nil, nil, err } if !a.IsActive() || a.IsArchived() { return nil, nil, merrors.Unauthorized("inactive account") } return a, token, nil } func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc { var req challengeRequest if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } if req.ChallengeID == "" { return pr.flowError(merrors.InvalidArgument("challenge ID required")) } a, t, err := pr.pending(r.Context(), req.PendingToken) if err != nil { return pr.flowError(err) } return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID) } func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc { var req challengeRequest if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } a, t, err := pr.pending(r.Context(), req.PendingToken) if err != nil { return pr.flowError(err) } client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID) if h != nil { return h } if h = pr.validateClientAccountBinding(client, a); h != nil { return h } if pr.factors == nil { return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable")) } scope := loginScope(req.PendingToken) var result http.HandlerFunc err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error { var err error result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a) return err }) if err != nil { return pr.flowError(err) } pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex())) return result }