Integrate email notifications into signup and login (#38)
This commit is contained in:
1 parent
326d58f705
commit
ad5a061164
55 files changed
+1859
-3464
No files matched your search
@@ -0,0 +1,240 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"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"
|
||||
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"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
||||
"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 {
|
||||
PendingToken string `json:"pendingToken"`
|
||||
Code string `json:"code"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
type challengeResponse struct {
|
||||
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 (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key string) http.HandlerFunc {
|
||||
expires, err := pr.flow.Issue(r.Context(), a, verificationmodel.PurposeLogin, challengeScope(token.Token), key, token.Expiration)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if token.Expiration.Before(expires) {
|
||||
expires = token.Expiration
|
||||
}
|
||||
pr.logger.Info("Login verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, challengeResponse{UserID: a.ID.Hex(), PendingToken: token, Target: mask.Email(a.Login), ExpiresAt: expires, ResendAt: time.Now().Add(flow.Cooldown)})
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if len(code) != 6 {
|
||||
return pr.flowError(verification.ErorrTokenNotFound())
|
||||
}
|
||||
if _, err = pr.flow.Tokens.Consume(r.Context(), a.ID, verificationmodel.PurposeLogin, challengeScope(req.PendingToken), code); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login verification completed", zap.String("account_ref", a.ID.Hex()))
|
||||
return pr.refreshAndRespondLogin(r.Context(), r, &t.SessionIdentifier, a)
|
||||
}
|
||||
Reference in new issue
Block a user