authkit initial

This commit is contained in:
juancwu 2026-04-26 01:36:53 +00:00
commit 134393fbca
43 changed files with 5188 additions and 1 deletions

178
service_user.go Normal file
View 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)
}