mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 03:22:09 +00:00
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).
- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
parameter, JSON null arrays, expires_at timezone casts, passkey list
GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
609 lines
20 KiB
Go
609 lines
20 KiB
Go
package direct
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
|
)
|
|
|
|
const sessionLifetime = 24 * time.Hour
|
|
|
|
// AuthOptions tunes Auth.
|
|
type AuthOptions struct {
|
|
// UpgradePasswordHash rewrites legacy cleartext passwords as bcrypt on a successful login.
|
|
UpgradePasswordHash bool
|
|
}
|
|
|
|
// Auth implements lookup.AuthStore on the tables. Passwords are verified with bcrypt;
|
|
// legacy cleartext rows are accepted at login and only rewritten when UpgradePasswordHash
|
|
// is set. Registration never honours client-supplied user_level or roles. Multi-step writes
|
|
// (login, register, refresh, reset) run in one transaction.
|
|
type Auth struct {
|
|
*Base
|
|
opts AuthOptions
|
|
}
|
|
|
|
var _ lookup.AuthStore = (*Auth)(nil)
|
|
|
|
// NewAuth creates the direct AuthStore.
|
|
func NewAuth(b *Base, opts AuthOptions) *Auth { return &Auth{Base: b, opts: opts} }
|
|
|
|
// GenerateSessionToken returns "sess_<64 hex>_<unix>".
|
|
func GenerateSessionToken() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("sess_%s_%d", hex.EncodeToString(buf), time.Now().Unix()), nil
|
|
}
|
|
|
|
// ParseRoles splits the comma-separated roles column.
|
|
func ParseRoles(s string) []string {
|
|
if s == "" {
|
|
return []string{}
|
|
}
|
|
return strings.Split(s, ",")
|
|
}
|
|
|
|
func claimStrings(claims map[string]any) (ip, ua string) {
|
|
if claims == nil {
|
|
return "", ""
|
|
}
|
|
if v, ok := claims["ip_address"].(string); ok {
|
|
ip = v
|
|
}
|
|
if v, ok := claims["user_agent"].(string); ok {
|
|
ua = v
|
|
}
|
|
return ip, ua
|
|
}
|
|
|
|
func sha256Hex(s string) string {
|
|
h := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// userRow is the users columns every session-bearing response needs.
|
|
type userRow struct {
|
|
id int
|
|
username sql.NullString
|
|
email sql.NullString
|
|
roles sql.NullString
|
|
programUserTable sql.NullString
|
|
userLevel sql.NullInt64
|
|
programUserID sql.NullInt64
|
|
}
|
|
|
|
func (u *userRow) context(sessionID string) *sectypes.UserContext {
|
|
return §ypes.UserContext{
|
|
UserID: u.id,
|
|
UserName: u.username.String,
|
|
Email: u.email.String,
|
|
UserLevel: int(u.userLevel.Int64),
|
|
SessionID: sessionID,
|
|
Roles: ParseRoles(u.roles.String),
|
|
ProgramUserID: int(u.programUserID.Int64),
|
|
ProgramUserTable: u.programUserTable.String,
|
|
}
|
|
}
|
|
|
|
// insertSession writes a session row and stamps the user's last login.
|
|
func (a *Auth) insertSession(ctx context.Context, q Querier, token string, userID int64, expiresAt time.Time, ip, ua string, now time.Time) error {
|
|
err := a.Insert(lookup.EntityUserSessions).Set(
|
|
Set(lookup.SessionsToken, token),
|
|
Set(lookup.SessionsUserID, userID),
|
|
Set(lookup.SessionsExpiresAt, expiresAt),
|
|
Set(lookup.SessionsIPAddress, ip),
|
|
Set(lookup.SessionsUserAgent, ua),
|
|
Set(lookup.SessionsLastActivityAt, now),
|
|
Set(lookup.SessionsCreatedAt, now),
|
|
).Exec(ctx, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.touchLastLogin(ctx, q, userID, now)
|
|
}
|
|
|
|
func (a *Auth) touchLastLogin(ctx context.Context, q Querier, userID int64, now time.Time) error {
|
|
_, err := a.Update(lookup.EntityUsers).Set(Set(lookup.UsersLastLoginAt, now)).Where(Eq(lookup.UsersID, userID)).Exec(ctx, q)
|
|
return err
|
|
}
|
|
|
|
// Login implements lookup.AuthStore.
|
|
func (a *Auth) Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
|
|
var userID int
|
|
var email, roles, programUserTable, storedPassword sql.NullString
|
|
var userLevel, programUserID sql.NullInt64
|
|
|
|
err := a.do(func(q Querier) error {
|
|
return a.From(lookup.EntityUsers).
|
|
Cols(lookup.UsersID, lookup.UsersEmail, lookup.UsersUserLevel, lookup.UsersRoles,
|
|
lookup.UsersProgramUserID, lookup.UsersProgramUserTable, lookup.UsersPassword).
|
|
Where(Eq(lookup.UsersUsername, req.Username), Eq(lookup.UsersIsActive, true)).
|
|
QueryRow(ctx, q, &userID, &email, &userLevel, &roles, &programUserID, &programUserTable, &storedPassword)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
BurnPasswordCheck(req.Password)
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
return nil, fmt.Errorf("login query failed: %w", err)
|
|
}
|
|
|
|
ok, needsRehash := VerifyPassword(storedPassword.String, req.Password)
|
|
if !ok {
|
|
if storedPassword.String == "" {
|
|
BurnPasswordCheck(req.Password)
|
|
}
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
if needsRehash && a.opts.UpgradePasswordHash {
|
|
a.upgradePasswordHash(ctx, userID, req.Password)
|
|
}
|
|
|
|
token, err := GenerateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
now := a.Now()
|
|
ip, ua := claimStrings(req.Claims)
|
|
err = a.tx(ctx, func(q Querier) error {
|
|
return a.insertSession(ctx, q, token, int64(userID), now.Add(sessionLifetime), ip, ua, now)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login query failed: %w", err)
|
|
}
|
|
|
|
return §ypes.LoginResponse{
|
|
Token: token,
|
|
User: §ypes.UserContext{
|
|
UserID: userID,
|
|
UserName: req.Username,
|
|
Email: email.String,
|
|
UserLevel: int(userLevel.Int64),
|
|
Roles: ParseRoles(roles.String),
|
|
SessionID: token,
|
|
ProgramUserID: int(programUserID.Int64),
|
|
ProgramUserTable: programUserTable.String,
|
|
},
|
|
ExpiresIn: int64(sessionLifetime.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// upgradePasswordHash replaces a legacy cleartext password with a bcrypt hash. Failure is
|
|
// logged and ignored: the login itself already succeeded.
|
|
func (a *Auth) upgradePasswordHash(ctx context.Context, userID int, password string) {
|
|
h, err := HashPassword(password)
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = a.do(func(q Querier) error {
|
|
_, err := a.Update(lookup.EntityUsers).
|
|
Set(Set(lookup.UsersPassword, h), Set(lookup.UsersUpdatedAt, a.Now())).
|
|
Where(Eq(lookup.UsersID, userID)).Exec(ctx, q)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
logger.Warn("failed to upgrade legacy password hash for user %d: %v", userID, err)
|
|
}
|
|
}
|
|
|
|
// Register implements lookup.AuthStore.
|
|
func (a *Auth) Register(ctx context.Context, req sectypes.RegisterRequest) (*sectypes.LoginResponse, error) {
|
|
if req.Username == "" {
|
|
return nil, fmt.Errorf("username is required")
|
|
}
|
|
if req.Email == "" {
|
|
return nil, fmt.Errorf("email is required")
|
|
}
|
|
if req.Password == "" {
|
|
return nil, fmt.Errorf("password is required")
|
|
}
|
|
hash, err := HashPassword(req.Password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := GenerateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
|
|
// Privileges are never taken from the request: self-registration always creates an
|
|
// unprivileged user.
|
|
const userLevel = 0
|
|
now := a.Now()
|
|
ip, ua := claimStrings(req.Claims)
|
|
|
|
var userID int64
|
|
err = a.tx(ctx, func(q Querier) error {
|
|
exists, err := a.From(lookup.EntityUsers).Cols(lookup.UsersID).Where(Eq(lookup.UsersUsername, req.Username)).Exists(ctx, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return lookup.ErrUsernameExists
|
|
}
|
|
exists, err = a.From(lookup.EntityUsers).Cols(lookup.UsersID).Where(Eq(lookup.UsersEmail, req.Email)).Exists(ctx, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return lookup.ErrEmailExists
|
|
}
|
|
userID, err = a.Insert(lookup.EntityUsers).Set(
|
|
Set(lookup.UsersUsername, req.Username),
|
|
Set(lookup.UsersEmail, req.Email),
|
|
Set(lookup.UsersPassword, hash),
|
|
Set(lookup.UsersUserLevel, userLevel),
|
|
Set(lookup.UsersRoles, ""),
|
|
Set(lookup.UsersIsActive, true),
|
|
Set(lookup.UsersCreatedAt, now),
|
|
Set(lookup.UsersUpdatedAt, now),
|
|
Set(lookup.UsersProgramUserID, 0),
|
|
Set(lookup.UsersProgramUserTable, ""),
|
|
).ExecID(ctx, q, lookup.UsersID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.insertSession(ctx, q, token, userID, now.Add(sessionLifetime), ip, ua, now)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, lookup.ErrUsernameExists) || errors.Is(err, lookup.ErrEmailExists) {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("register query failed: %w", err)
|
|
}
|
|
|
|
return §ypes.LoginResponse{
|
|
Token: token,
|
|
User: §ypes.UserContext{
|
|
UserID: int(userID),
|
|
UserName: req.Username,
|
|
Email: req.Email,
|
|
UserLevel: userLevel,
|
|
Roles: ParseRoles(""),
|
|
SessionID: token,
|
|
},
|
|
ExpiresIn: int64(sessionLifetime.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// Logout implements lookup.AuthStore.
|
|
func (a *Auth) Logout(ctx context.Context, req sectypes.LogoutRequest) error {
|
|
token := strings.TrimPrefix(strings.TrimPrefix(req.Token, "Bearer "), "bearer ")
|
|
var rows int64
|
|
err := a.do(func(q Querier) error {
|
|
var err error
|
|
rows, err = a.Delete(lookup.EntityUserSessions).
|
|
Where(Eq(lookup.SessionsToken, token), Eq(lookup.SessionsUserID, req.UserID)).Exec(ctx, q)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("logout query failed: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("session not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sessionUser selects the user behind a live session token.
|
|
func (a *Auth) sessionUser(ctx context.Context, q Querier, token string, extra ...lookup.Column) (*userRow, []any, error) {
|
|
var u userRow
|
|
dest := []any{&u.id, &u.username, &u.email, &u.userLevel, &u.roles, &u.programUserID, &u.programUserTable}
|
|
cols := []lookup.Column{lookup.SessionsUserID, lookup.UsersUsername, lookup.UsersEmail, lookup.UsersUserLevel,
|
|
lookup.UsersRoles, lookup.UsersProgramUserID, lookup.UsersProgramUserTable}
|
|
extras := make([]any, len(extra))
|
|
for i, c := range extra {
|
|
cols = append(cols, c)
|
|
extras[i] = new(sql.NullString)
|
|
dest = append(dest, extras[i])
|
|
}
|
|
err := a.From(lookup.EntityUserSessions).Cols(cols...).
|
|
Join(lookup.EntityUsers, EqCol(lookup.SessionsUserID, lookup.UsersID)).
|
|
Where(Eq(lookup.SessionsToken, token), Gt(lookup.SessionsExpiresAt, a.Now()), Eq(lookup.UsersIsActive, true)).
|
|
QueryRow(ctx, q, dest...)
|
|
return &u, extras, err
|
|
}
|
|
|
|
// Session implements lookup.AuthStore. reference is only meaningful to the procedure backend.
|
|
func (a *Auth) Session(ctx context.Context, token, _ string) (*sectypes.UserContext, error) {
|
|
var u *userRow
|
|
err := a.do(func(q Querier) error {
|
|
var err error
|
|
u, _, err = a.sessionUser(ctx, q, token)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("invalid or expired session")
|
|
}
|
|
return nil, fmt.Errorf("session query failed: %w", err)
|
|
}
|
|
return u.context(token), nil
|
|
}
|
|
|
|
// TouchSession implements lookup.AuthStore.
|
|
func (a *Auth) TouchSession(ctx context.Context, token string, _ *sectypes.UserContext) error {
|
|
return a.do(func(q Querier) error {
|
|
now := a.Now()
|
|
_, err := a.Update(lookup.EntityUserSessions).Set(Set(lookup.SessionsLastActivityAt, now)).
|
|
Where(Eq(lookup.SessionsToken, token), Gt(lookup.SessionsExpiresAt, now)).Exec(ctx, q)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Refresh implements lookup.AuthStore: the old session is replaced by a new one.
|
|
func (a *Auth) Refresh(ctx context.Context, oldToken string) (*sectypes.LoginResponse, error) {
|
|
var u *userRow
|
|
var extras []any
|
|
err := a.do(func(q Querier) error {
|
|
var err error
|
|
u, extras, err = a.sessionUser(ctx, q, oldToken, lookup.SessionsIPAddress, lookup.SessionsUserAgent)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("invalid or expired refresh token")
|
|
}
|
|
return nil, fmt.Errorf("refresh token query failed: %w", err)
|
|
}
|
|
ip := extras[0].(*sql.NullString).String
|
|
ua := extras[1].(*sql.NullString).String
|
|
|
|
newToken, err := GenerateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
now := a.Now()
|
|
err = a.tx(ctx, func(q Querier) error {
|
|
err := a.Insert(lookup.EntityUserSessions).Set(
|
|
Set(lookup.SessionsToken, newToken),
|
|
Set(lookup.SessionsUserID, u.id),
|
|
Set(lookup.SessionsExpiresAt, now.Add(sessionLifetime)),
|
|
Set(lookup.SessionsIPAddress, ip),
|
|
Set(lookup.SessionsUserAgent, ua),
|
|
Set(lookup.SessionsLastActivityAt, now),
|
|
Set(lookup.SessionsCreatedAt, now),
|
|
).Exec(ctx, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = a.Delete(lookup.EntityUserSessions).Where(Eq(lookup.SessionsToken, oldToken)).Exec(ctx, q)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh token generation failed: %w", err)
|
|
}
|
|
return §ypes.LoginResponse{
|
|
Token: newToken,
|
|
User: u.context(newToken),
|
|
ExpiresIn: int64(sessionLifetime.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// apiKeyTypes are the key types accepted by LoginAPIKey.
|
|
var apiKeyTypes = []any{string(sectypes.KeyTypeHeaderAPI), string(sectypes.KeyTypeGenericAPI)}
|
|
|
|
// LoginAPIKey implements lookup.AuthStore. Unknown, expired, inactive and wrong-type keys
|
|
// (and inactive users) all return lookup.ErrInvalidAPIKey; the raw key is never logged.
|
|
func (a *Auth) LoginAPIKey(ctx context.Context, rawKey string, claims map[string]any) (*sectypes.LoginResponse, error) {
|
|
if rawKey == "" {
|
|
return nil, lookup.ErrInvalidAPIKey
|
|
}
|
|
now := a.Now()
|
|
var keyID int64
|
|
var u userRow
|
|
err := a.do(func(q Querier) error {
|
|
return a.From(lookup.EntityUserKeys).
|
|
Cols(lookup.KeysID, lookup.UsersID, lookup.UsersUsername, lookup.UsersEmail, lookup.UsersUserLevel,
|
|
lookup.UsersRoles, lookup.UsersProgramUserID, lookup.UsersProgramUserTable).
|
|
Join(lookup.EntityUsers, EqCol(lookup.KeysUserID, lookup.UsersID)).
|
|
Where(
|
|
Eq(lookup.KeysKeyHash, sectypes.HashKey(rawKey)),
|
|
In(lookup.KeysKeyType, apiKeyTypes...),
|
|
Eq(lookup.KeysIsActive, true),
|
|
Or(IsNull(lookup.KeysExpiresAt), Gt(lookup.KeysExpiresAt, now)),
|
|
Eq(lookup.UsersIsActive, true),
|
|
).QueryRow(ctx, q, &keyID, &u.id, &u.username, &u.email, &u.userLevel, &u.roles, &u.programUserID, &u.programUserTable)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, lookup.ErrInvalidAPIKey
|
|
}
|
|
return nil, fmt.Errorf("api key login query failed: %w", err)
|
|
}
|
|
|
|
token, err := GenerateSessionToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
ip, ua := claimStrings(claims)
|
|
err = a.tx(ctx, func(q Querier) error {
|
|
if err := a.insertSession(ctx, q, token, int64(u.id), now.Add(sessionLifetime), ip, ua, now); err != nil {
|
|
return err
|
|
}
|
|
_, err := a.Update(lookup.EntityUserKeys).Set(Set(lookup.KeysLastUsedAt, now)).Where(Eq(lookup.KeysID, keyID)).Exec(ctx, q)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("api key login query failed: %w", err)
|
|
}
|
|
return §ypes.LoginResponse{
|
|
Token: token,
|
|
User: u.context(token),
|
|
ExpiresIn: int64(sessionLifetime.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// JWTLogin implements lookup.AuthStore (mirrors resolvespec_jwt_login). The token is a
|
|
// placeholder until JWT signing is wired in.
|
|
func (a *Auth) JWTLogin(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
|
|
var userID int
|
|
var email, roles, storedPassword sql.NullString
|
|
var userLevel sql.NullInt64
|
|
err := a.do(func(q Querier) error {
|
|
return a.From(lookup.EntityUsers).
|
|
Cols(lookup.UsersID, lookup.UsersEmail, lookup.UsersUserLevel, lookup.UsersRoles, lookup.UsersPassword).
|
|
Where(Eq(lookup.UsersUsername, req.Username), Eq(lookup.UsersIsActive, true)).
|
|
QueryRow(ctx, q, &userID, &email, &userLevel, &roles, &storedPassword)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
BurnPasswordCheck(req.Password)
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
return nil, fmt.Errorf("login query failed: %w", err)
|
|
}
|
|
ok, needsRehash := VerifyPassword(storedPassword.String, req.Password)
|
|
if !ok {
|
|
if storedPassword.String == "" {
|
|
BurnPasswordCheck(req.Password)
|
|
}
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
if needsRehash && a.opts.UpgradePasswordHash {
|
|
a.upgradePasswordHash(ctx, userID, req.Password)
|
|
}
|
|
expiresAt := a.Now().Add(sessionLifetime)
|
|
return §ypes.LoginResponse{
|
|
Token: fmt.Sprintf("token_%d_%d", userID, expiresAt.Unix()),
|
|
User: §ypes.UserContext{
|
|
UserID: userID,
|
|
UserName: req.Username,
|
|
Email: email.String,
|
|
UserLevel: int(userLevel.Int64),
|
|
Roles: ParseRoles(roles.String),
|
|
},
|
|
ExpiresIn: int64(sessionLifetime.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// JWTLogout implements lookup.AuthStore: the token goes on the blacklist.
|
|
func (a *Auth) JWTLogout(ctx context.Context, req sectypes.LogoutRequest) error {
|
|
now := a.Now()
|
|
err := a.do(func(q Querier) error {
|
|
return a.Insert(lookup.EntityTokenBlacklist).Set(
|
|
Set(lookup.BlacklistToken, req.Token),
|
|
Set(lookup.BlacklistUserID, req.UserID),
|
|
Set(lookup.BlacklistExpiresAt, now.Add(sessionLifetime)),
|
|
Set(lookup.BlacklistCreatedAt, now),
|
|
).Exec(ctx, q)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("logout query failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResetRequest implements lookup.AuthStore. An unknown user yields a generic empty success
|
|
// so accounts cannot be enumerated.
|
|
func (a *Auth) ResetRequest(ctx context.Context, req sectypes.PasswordResetRequest) (*sectypes.PasswordResetResponse, error) {
|
|
if req.Email == "" && req.Username == "" {
|
|
return nil, fmt.Errorf("email or username is required")
|
|
}
|
|
var userID int
|
|
err := a.do(func(q Querier) error {
|
|
lookupCol, val := lookup.UsersUsername, req.Username
|
|
if req.Email != "" {
|
|
lookupCol, val = lookup.UsersEmail, req.Email
|
|
}
|
|
return a.From(lookup.EntityUsers).Cols(lookup.UsersID).
|
|
Where(Eq(lookupCol, val), Eq(lookup.UsersIsActive, true)).QueryRow(ctx, q, &userID)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return §ypes.PasswordResetResponse{Token: "", ExpiresIn: 0}, nil
|
|
}
|
|
return nil, fmt.Errorf("password reset request query failed: %w", err)
|
|
}
|
|
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return nil, fmt.Errorf("failed to generate reset token: %w", err)
|
|
}
|
|
rawToken := hex.EncodeToString(raw)
|
|
now := a.Now()
|
|
err = a.tx(ctx, func(q Querier) error {
|
|
if _, err := a.Delete(lookup.EntityUserPasswordResets).
|
|
Where(Eq(lookup.ResetsUserID, userID), Eq(lookup.ResetsUsed, false)).Exec(ctx, q); err != nil {
|
|
return err
|
|
}
|
|
return a.Insert(lookup.EntityUserPasswordResets).Set(
|
|
Set(lookup.ResetsUserID, userID),
|
|
Set(lookup.ResetsTokenHash, sha256Hex(rawToken)),
|
|
Set(lookup.ResetsExpiresAt, now.Add(time.Hour)),
|
|
Set(lookup.ResetsCreatedAt, now),
|
|
Set(lookup.ResetsUsed, false),
|
|
).Exec(ctx, q)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("password reset request query failed: %w", err)
|
|
}
|
|
return §ypes.PasswordResetResponse{Token: rawToken, ExpiresIn: 3600}, nil
|
|
}
|
|
|
|
// ResetComplete implements lookup.AuthStore: sets the new password, ends every session of
|
|
// the user and consumes the reset token, atomically.
|
|
func (a *Auth) ResetComplete(ctx context.Context, req sectypes.PasswordResetCompleteRequest) error {
|
|
if req.Token == "" {
|
|
return fmt.Errorf("token is required")
|
|
}
|
|
if req.NewPassword == "" {
|
|
return fmt.Errorf("new_password is required")
|
|
}
|
|
newHash, err := HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tokenHash := sha256Hex(req.Token)
|
|
|
|
now := a.Now()
|
|
var resetID, userID int
|
|
var expiresAt time.Time
|
|
err = a.do(func(q Querier) error {
|
|
return a.From(lookup.EntityUserPasswordResets).
|
|
Cols(lookup.ResetsID, lookup.ResetsUserID, lookup.ResetsExpiresAt).
|
|
Where(Eq(lookup.ResetsTokenHash, tokenHash), Eq(lookup.ResetsUsed, false)).
|
|
QueryRow(ctx, q, &resetID, &userID, a.timeDest(&expiresAt))
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("invalid or expired token")
|
|
}
|
|
return fmt.Errorf("password reset complete query failed: %w", err)
|
|
}
|
|
if !expiresAt.After(now) {
|
|
return fmt.Errorf("invalid or expired token")
|
|
}
|
|
|
|
err = a.tx(ctx, func(q Querier) error {
|
|
if _, err := a.Update(lookup.EntityUsers).
|
|
Set(Set(lookup.UsersPassword, newHash), Set(lookup.UsersUpdatedAt, now)).
|
|
Where(Eq(lookup.UsersID, userID)).Exec(ctx, q); err != nil {
|
|
return err
|
|
}
|
|
if _, err := a.Delete(lookup.EntityUserSessions).Where(Eq(lookup.SessionsUserID, userID)).Exec(ctx, q); err != nil {
|
|
return err
|
|
}
|
|
_, err := a.Update(lookup.EntityUserPasswordResets).
|
|
Set(Set(lookup.ResetsUsed, true), Set(lookup.ResetsUsedAt, now)).
|
|
Where(Eq(lookup.ResetsID, resetID)).Exec(ctx, q)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("password reset complete query failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|