260 lines
9.8 KiB
Go
260 lines
9.8 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
|
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
|
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, 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
|
|
}
|