authkit initial
This commit is contained in:
parent
5173b0a43d
commit
134393fbca
43 changed files with 5188 additions and 1 deletions
178
service_user.go
Normal file
178
service_user.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package authkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.juancwu.dev/juancwu/errx"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// normalizeEmail produces the lookup form used by UserStore.GetUserByEmail
|
||||
// and the email_normalized column. Trim + lowercase is intentional; we do
|
||||
// not collapse Gmail-style "+" addressing or strip dots — that's a policy
|
||||
// decision callers can layer on top.
|
||||
func normalizeEmail(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// Register creates a new user with an Argon2id-hashed password. Returns
|
||||
// ErrEmailTaken if the normalized email is already registered.
|
||||
func (a *Auth) Register(ctx context.Context, email, password string) (*User, error) {
|
||||
const op = "authkit.Auth.Register"
|
||||
if email == "" || password == "" {
|
||||
return nil, errx.Wrap(op, ErrInvalidCredentials)
|
||||
}
|
||||
hash, err := a.deps.Hasher.Hash(password)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
now := a.now()
|
||||
u := &User{
|
||||
ID: uuid.New(),
|
||||
Email: email,
|
||||
EmailNormalized: normalizeEmail(email),
|
||||
PasswordHash: hash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := a.deps.Users.CreateUser(ctx, u); err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// LoginPassword verifies the password and returns the authenticated user.
|
||||
// Failure increments failed_logins; success resets it and stamps last_login_at.
|
||||
// LoginHook (if configured) is invoked with the success outcome — use this to
|
||||
// hook in rate limiting or audit logging.
|
||||
func (a *Auth) LoginPassword(ctx context.Context, email, password string) (*User, error) {
|
||||
const op = "authkit.Auth.LoginPassword"
|
||||
u, err := a.deps.Users.GetUserByEmail(ctx, normalizeEmail(email))
|
||||
if err != nil {
|
||||
_ = a.fireLoginHook(ctx, email, false)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return nil, errx.Wrap(op, ErrInvalidCredentials)
|
||||
}
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
_ = a.fireLoginHook(ctx, email, false)
|
||||
return nil, errx.Wrap(op, ErrInvalidCredentials)
|
||||
}
|
||||
ok, needsRehash, err := a.deps.Hasher.Verify(password, u.PasswordHash)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
if !ok {
|
||||
_, _ = a.deps.Users.IncrementFailedLogins(ctx, u.ID)
|
||||
_ = a.fireLoginHook(ctx, email, false)
|
||||
return nil, errx.Wrap(op, ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
now := a.now()
|
||||
u.LastLoginAt = &now
|
||||
u.FailedLogins = 0
|
||||
if err := a.deps.Users.ResetFailedLogins(ctx, u.ID); err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
if err := a.deps.Users.UpdateUser(ctx, u); err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
|
||||
if needsRehash {
|
||||
if newHash, herr := a.deps.Hasher.Hash(password); herr == nil {
|
||||
_ = a.deps.Users.SetPassword(ctx, u.ID, newHash)
|
||||
u.PasswordHash = newHash
|
||||
}
|
||||
}
|
||||
_ = a.fireLoginHook(ctx, email, true)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ChangePassword verifies the current password, sets the new one, and bumps
|
||||
// the user's session_version so all outstanding JWT access tokens are
|
||||
// instantly invalidated. Outstanding opaque sessions are also revoked.
|
||||
func (a *Auth) ChangePassword(ctx context.Context, userID uuid.UUID, oldPassword, newPassword string) error {
|
||||
const op = "authkit.Auth.ChangePassword"
|
||||
u, err := a.deps.Users.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
return errx.Wrap(op, ErrInvalidCredentials)
|
||||
}
|
||||
ok, _, err := a.deps.Hasher.Verify(oldPassword, u.PasswordHash)
|
||||
if err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
if !ok {
|
||||
return errx.Wrap(op, ErrInvalidCredentials)
|
||||
}
|
||||
newHash, err := a.deps.Hasher.Hash(newPassword)
|
||||
if err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
if err := a.deps.Users.SetPassword(ctx, userID, newHash); err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
if _, err := a.deps.Users.BumpSessionVersion(ctx, userID); err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
if err := a.deps.Sessions.DeleteUserSessions(ctx, userID); err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestEmailVerification mints a single-use email-verify token for the
|
||||
// user. Return the plaintext to the caller so they can put it in an email
|
||||
// link; the lookup hash is stored in TokenStore.
|
||||
func (a *Auth) RequestEmailVerification(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
const op = "authkit.Auth.RequestEmailVerification"
|
||||
plaintext, hash, err := mintSecret(prefixEmailVerify, a.cfg.Random)
|
||||
if err != nil {
|
||||
return "", errx.Wrap(op, err)
|
||||
}
|
||||
now := a.now()
|
||||
t := &Token{
|
||||
Hash: hash,
|
||||
Kind: TokenEmailVerify,
|
||||
UserID: userID,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(a.cfg.EmailVerifyTTL),
|
||||
}
|
||||
if err := a.deps.Tokens.CreateToken(ctx, t); err != nil {
|
||||
return "", errx.Wrap(op, err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// ConfirmEmail consumes the verification token and marks the user's email
|
||||
// verified. Returns ErrTokenInvalid if the token is missing/expired/used.
|
||||
func (a *Auth) ConfirmEmail(ctx context.Context, plaintextToken string) (*User, error) {
|
||||
const op = "authkit.Auth.ConfirmEmail"
|
||||
hash, ok := parseSecret(prefixEmailVerify, plaintextToken)
|
||||
if !ok {
|
||||
return nil, errx.Wrap(op, ErrTokenInvalid)
|
||||
}
|
||||
now := a.now()
|
||||
t, err := a.deps.Tokens.ConsumeToken(ctx, TokenEmailVerify, hash, now)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
if err := a.deps.Users.SetEmailVerified(ctx, t.UserID, now); err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
return a.deps.Users.GetUserByID(ctx, t.UserID)
|
||||
}
|
||||
|
||||
// fireLoginHook is a thin wrapper that suppresses panics from caller-supplied
|
||||
// hooks; we never want a misbehaving telemetry hook to break login.
|
||||
func (a *Auth) fireLoginHook(ctx context.Context, email string, success bool) error {
|
||||
if a.cfg.LoginHook == nil {
|
||||
return nil
|
||||
}
|
||||
return a.cfg.LoginHook(ctx, email, success)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue