mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-21 10:03:21 +00:00
313 lines
11 KiB
Go
313 lines
11 KiB
Go
// Package auth implements local username/password auth with bcrypt hashing
|
|
// and server-side sessions backed by an HTTP-only cookie.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"ferrum/internal/store"
|
|
)
|
|
|
|
const (
|
|
SessionCookieName = "ferrum_session"
|
|
sessionTTL = 30 * 24 * time.Hour
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
|
ErrAlreadyBootstrapped = errors.New("an admin user already exists")
|
|
)
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
IsAdmin bool `json:"isAdmin"`
|
|
TOTPEnabled bool `json:"totpEnabled"`
|
|
}
|
|
|
|
type Service struct {
|
|
db *store.DB
|
|
}
|
|
|
|
func NewService(db *store.DB) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
// NeedsSetup reports whether no user exists yet, meaning the frontend should
|
|
// show the first-run setup wizard instead of the login page.
|
|
func (s *Service) NeedsSetup(ctx context.Context) (bool, error) {
|
|
var count int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
|
return false, err
|
|
}
|
|
return count == 0, nil
|
|
}
|
|
|
|
// Bootstrap creates the first admin user. It refuses to run if any user already exists.
|
|
func (s *Service) Bootstrap(ctx context.Context, username, email, password string) (*User, error) {
|
|
needsSetup, err := s.NeedsSetup(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !needsSetup {
|
|
return nil, ErrAlreadyBootstrapped
|
|
}
|
|
return s.createUser(ctx, username, email, password, true)
|
|
}
|
|
|
|
func (s *Service) createUser(ctx context.Context, username, email, password string, isAdmin bool) (*User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id := uuid.NewString()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO users (id, username, email, password_hash, is_admin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
id, username, email, string(hash), boolToInt(isAdmin), now, now,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &User{ID: id, Username: username, Email: email, IsAdmin: isAdmin}, nil
|
|
}
|
|
|
|
// VerifyPassword checks username/password and returns the matching user
|
|
// without creating a session — the caller decides whether a second TOTP
|
|
// step is required (User.TOTPEnabled) before calling CreateSession.
|
|
// dummyBcryptHash is compared against when the username doesn't exist so
|
|
// "unknown user" and "wrong password" take the same time — otherwise the
|
|
// missing bcrypt pass leaks valid usernames through response timing.
|
|
var dummyBcryptHash = func() []byte {
|
|
// bcrypt hash of a random never-used password
|
|
return []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
|
|
}()
|
|
|
|
func (s *Service) VerifyPassword(ctx context.Context, username, password string) (*User, error) {
|
|
var (
|
|
id, uname, email, hash string
|
|
isAdmin, totpEnabled int
|
|
)
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT id, username, email, password_hash, is_admin, totp_enabled FROM users WHERE username = ?`,
|
|
username,
|
|
).Scan(&id, &uname, &email, &hash, &isAdmin, &totpEnabled)
|
|
if err != nil {
|
|
// Spend the same bcrypt work a real user lookup would, then fail.
|
|
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return &User{ID: id, Username: uname, Email: email, IsAdmin: isAdmin == 1, TOTPEnabled: totpEnabled == 1}, nil
|
|
}
|
|
|
|
// FindOrCreateOIDCUser resolves a verified OIDC identity to a local user:
|
|
// an existing user already linked to this subject, an existing local user
|
|
// with the same email (linked on first SSO login), or — failing both — a
|
|
// newly provisioned account. New OIDC accounts are never admins; promote
|
|
// them from the Users page like any other account. They get a random,
|
|
// never-disclosed password hash so the (unused) password login path stays
|
|
// closed for them, without needing a schema change to make password_hash
|
|
// nullable.
|
|
//
|
|
// emailVerified gates the risky paths: an unverified email address is never
|
|
// used to link into (or name-collide with) an existing account, because a
|
|
// hostile IdP account claiming someone else's address would otherwise take
|
|
// that account over. Unverified users get a placeholder email instead.
|
|
func (s *Service) FindOrCreateOIDCUser(ctx context.Context, subject, email, preferredUsername string, emailVerified bool) (*User, error) {
|
|
var (
|
|
id, uname, mail string
|
|
isAdmin, totpEnabled int
|
|
)
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT id, username, email, is_admin, totp_enabled FROM users WHERE oidc_subject = ?`, subject,
|
|
).Scan(&id, &uname, &mail, &isAdmin, &totpEnabled)
|
|
if err == nil {
|
|
return &User{ID: id, Username: uname, Email: mail, IsAdmin: isAdmin == 1, TOTPEnabled: totpEnabled == 1}, nil
|
|
}
|
|
|
|
if email != "" && emailVerified {
|
|
err = s.db.QueryRowContext(ctx,
|
|
`SELECT id, username, email, is_admin, totp_enabled FROM users WHERE email = ?`, email,
|
|
).Scan(&id, &uname, &mail, &isAdmin, &totpEnabled)
|
|
if err == nil {
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE users SET oidc_subject = ? WHERE id = ?`, subject, id); err != nil {
|
|
return nil, err
|
|
}
|
|
return &User{ID: id, Username: uname, Email: mail, IsAdmin: isAdmin == 1, TOTPEnabled: totpEnabled == 1}, nil
|
|
}
|
|
} else if email != "" {
|
|
// A local account already holds this (unverified) address: refuse
|
|
// rather than silently creating a second user for the same email.
|
|
var exists int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`, email).Scan(&exists); err == nil && exists > 0 {
|
|
return nil, fmt.Errorf("SSO account email is not verified by the provider and collides with an existing local account")
|
|
}
|
|
}
|
|
|
|
username := preferredUsername
|
|
if username == "" {
|
|
username = email
|
|
}
|
|
if username == "" {
|
|
username = "sso-" + subject
|
|
}
|
|
username = s.uniqueUsername(ctx, username)
|
|
if email == "" || !emailVerified {
|
|
// users.email is NOT NULL UNIQUE; synthesize a placeholder that can't collide.
|
|
email = username + "@sso.local"
|
|
}
|
|
|
|
randomPassword := make([]byte, 32)
|
|
if _, err := rand.Read(randomPassword); err != nil {
|
|
return nil, err
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword(randomPassword, bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id = uuid.NewString()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO users (id, username, email, password_hash, is_admin, oidc_subject, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?, ?)`,
|
|
id, username, email, string(hash), subject, now, now,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &User{ID: id, Username: username, Email: email, IsAdmin: false}, nil
|
|
}
|
|
|
|
func (s *Service) uniqueUsername(ctx context.Context, base string) string {
|
|
candidate := base
|
|
for i := 2; i < 100; i++ {
|
|
var exists int
|
|
_ = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE username = ?`, candidate).Scan(&exists)
|
|
if exists == 0 {
|
|
return candidate
|
|
}
|
|
candidate = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
return base + "-" + uuid.NewString()[:8]
|
|
}
|
|
|
|
// Login verifies credentials and, on success, creates a new session and
|
|
// returns the session token to be set as a cookie. It is a convenience
|
|
// wrapper for the common case (no TOTP) — see VerifyPassword + CreateSession
|
|
// for the two-step flow used when TOTP is enabled.
|
|
func (s *Service) Login(ctx context.Context, username, password string) (*User, string, error) {
|
|
user, err := s.VerifyPassword(ctx, username, password)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
token, err := s.CreateSession(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return user, token, nil
|
|
}
|
|
|
|
// CreateSession mints a new session for an already-authenticated user.
|
|
// Opportunistically purges expired rows so the table doesn't grow forever.
|
|
func (s *Service) CreateSession(ctx context.Context, userID string) (string, error) {
|
|
token := randomToken()
|
|
now := time.Now().UTC()
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
|
uuid.NewString(), userID, hashToken(token), now.Format(time.RFC3339), now.Add(sessionTTL).Format(time.RFC3339),
|
|
); err != nil {
|
|
return "", err
|
|
}
|
|
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, now.Format(time.RFC3339))
|
|
return token, nil
|
|
}
|
|
|
|
// Authenticate resolves a session cookie value into the user that owns it.
|
|
func (s *Service) Authenticate(ctx context.Context, token string) (*User, error) {
|
|
if token == "" {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
var (
|
|
id, username, email string
|
|
isAdmin, totpEnabled int
|
|
expiresAt string
|
|
)
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT u.id, u.username, u.email, u.is_admin, u.totp_enabled, s.expires_at
|
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = ?`, hashToken(token),
|
|
).Scan(&id, &username, &email, &isAdmin, &totpEnabled, &expiresAt)
|
|
if err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
expiry, err := time.Parse(time.RFC3339, expiresAt)
|
|
if err != nil || time.Now().UTC().After(expiry) {
|
|
// Expired (or corrupt) session — drop it so the row can't accumulate.
|
|
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return &User{ID: id, Username: username, Email: email, IsAdmin: isAdmin == 1, TOTPEnabled: totpEnabled == 1}, nil
|
|
}
|
|
|
|
// Logout revokes the session associated with the given token.
|
|
func (s *Service) Logout(ctx context.Context, token string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
|
|
return err
|
|
}
|
|
|
|
func randomToken() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// SetSessionCookie writes the session cookie on the response.
|
|
func SetSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(sessionTTL),
|
|
})
|
|
}
|
|
|
|
// ClearSessionCookie expires the session cookie on the response.
|
|
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|