169 lines
5.6 KiB
Go
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)
|
|
}
|