Files
vpwg/Go/internal/auth/auth.go
T

169 lines
5.6 KiB
Go

// Package auth implements admin authentication: password hashing/verification,
// login, and (single) first-admin self-registration. See oidc.go for the Pocket ID
// SSO flow, which also creates the first admin automatically.
package auth
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"amnezia-share/internal/models"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// BcryptCost is the cost factor used for admin password hashes.
const BcryptCost = bcrypt.DefaultCost
// HashPassword hashes password with bcrypt at BcryptCost.
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return "", fmt.Errorf("хеширование пароля: %w", err)
}
return string(b), nil
}
// VerifyPassword reports whether password matches the bcrypt hash.
func VerifyPassword(hash, password string) bool {
if hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// ValidateUsername enforces the admin username policy: 3-64 chars, latin letters,
// digits, dot, dash, underscore only.
func ValidateUsername(username string) error {
if len(username) < 3 || len(username) > 64 {
return errors.New("Логин: от 3 до 64 символов.")
}
if !usernameRe.MatchString(username) {
return errors.New("Логин: только латиница, цифры, точка, дефис, подчёркивание.")
}
return nil
}
// ValidatePassword enforces the admin password policy: at least 10 characters.
func ValidatePassword(password string) error {
if len(password) < 10 {
return errors.New("Пароль не короче 10 символов.")
}
return nil
}
// Repo is the PostgreSQL-backed repository for admin accounts (the "users" table).
type Repo struct {
Pool *pgxpool.Pool
}
// New builds a Repo bound to pool.
func New(pool *pgxpool.Pool) *Repo {
return &Repo{Pool: pool}
}
// AdminCount returns the number of registered admin accounts.
func (r *Repo) AdminCount(ctx context.Context) (int, error) {
var c int
if err := r.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&c); err != nil {
return 0, err
}
return c, nil
}
// GetAdminByID loads an admin by id, or (nil, nil) if not found.
func (r *Repo) GetAdminByID(ctx context.Context, id int) (*models.User, error) {
if id <= 0 {
return nil, nil
}
var u models.User
err := r.Pool.QueryRow(ctx, `SELECT id, username, password_hash, role, created_at FROM users WHERE id=$1 LIMIT 1`, id).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// GetAdminByUsername loads an admin by exact username match, or (nil, nil) if not
// found.
func (r *Repo) GetAdminByUsername(ctx context.Context, username string) (*models.User, error) {
var u models.User
err := r.Pool.QueryRow(ctx, `SELECT id, username, password_hash, role, created_at FROM users WHERE username=$1 LIMIT 1`, username).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// LoginAdmin verifies username/password and returns the matching admin user.
func (r *Repo) LoginAdmin(ctx context.Context, username, password string) (*models.User, error) {
username = strings.TrimSpace(username)
u, err := r.GetAdminByUsername(ctx, username)
if err != nil {
return nil, errors.New("не удалось выполнить вход")
}
if u == nil || !VerifyPassword(u.PasswordHash, password) {
return nil, errors.New("Неверный логин или пароль.")
}
return u, nil
}
// RegisterFirstAdmin creates the single administrator account. It fails if any
// admin already exists (use the login form instead).
func (r *Repo) RegisterFirstAdmin(ctx context.Context, username, password string) (*models.User, error) {
count, err := r.AdminCount(ctx)
if err != nil {
return nil, errors.New("не удалось проверить администраторов")
}
if count > 0 {
return nil, errors.New("Администратор уже зарегистрирован. Войдите через форму входа.")
}
username = strings.TrimSpace(username)
if err := ValidateUsername(username); err != nil {
return nil, err
}
if err := ValidatePassword(password); err != nil {
return nil, err
}
hash, err := HashPassword(password)
if err != nil {
return nil, errors.New("Не удалось сформировать хеш пароля.")
}
var id int
err = r.Pool.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,'admin') RETURNING id`, username, hash).Scan(&id)
if err != nil {
return nil, errors.New("Ошибка БД: возможно, логин уже занят.")
}
return r.GetAdminByID(ctx, id)
}
// registerAdminWithHash inserts a new admin with a pre-computed password hash.
// Used by the OIDC auto-provisioning flow (oidc.go) where the local password is
// random and never used to sign in directly.
func (r *Repo) registerAdminWithHash(ctx context.Context, username, passwordHash string) (*models.User, error) {
var id int
err := r.Pool.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,'admin') RETURNING id`, username, passwordHash).Scan(&id)
if err != nil {
return nil, fmt.Errorf("создание администратора: %w", err)
}
return r.GetAdminByID(ctx, id)
}