Add Go rewrite with Postgres 17 and Dokploy Docker Compose
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user