dana-medical-site/internal/storage/admin.go
2026-10-14 13:33:13 +00:00

121 lines
3.5 KiB
Go

package storage
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
var ErrBadCredentials = errors.New("bad credentials")
const sessionTTL = 12 * time.Hour
type AdminUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
}
// EnsureAdmin создаёт первого администратора, если таблица пуста.
func (s *Storage) EnsureAdmin(ctx context.Context, email, password string) error {
if email == "" || password == "" {
return nil
}
var n int
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM admin_users`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx,
`INSERT INTO admin_users (email, password_hash, name, role) VALUES ($1,$2,'Администратор','owner')`,
email, string(hash))
return err
}
// Login проверяет пароль и заводит сессию, возвращая её токен.
func (s *Storage) Login(ctx context.Context, email, password, ip, ua string) (string, *AdminUser, error) {
var (
u AdminUser
hash string
)
err := s.pool.QueryRow(ctx,
`SELECT id, email, name, role, password_hash FROM admin_users WHERE email = $1 AND active`,
email).Scan(&u.ID, &u.Email, &u.Name, &u.Role, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil, ErrBadCredentials
}
if err != nil {
return "", nil, err
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return "", nil, ErrBadCredentials
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", nil, err
}
token := base64.RawURLEncoding.EncodeToString(raw)
var ipVal any
if ip != "" {
ipVal = ip
}
if _, err := s.pool.Exec(ctx,
`INSERT INTO admin_sessions (token, user_id, expires_at, ip, user_agent) VALUES ($1,$2,$3,$4,$5)`,
token, u.ID, time.Now().Add(sessionTTL), ipVal, ua); err != nil {
return "", nil, err
}
_, _ = s.pool.Exec(ctx, `UPDATE admin_users SET last_login_at = now() WHERE id = $1`, u.ID)
s.Audit(ctx, u.ID, "login", "admin_user", u.ID, ip)
return token, &u, nil
}
func (s *Storage) Logout(ctx context.Context, token string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token = $1`, token)
return err
}
// UserByToken возвращает пользователя действующей сессии.
func (s *Storage) UserByToken(ctx context.Context, token string) (*AdminUser, error) {
var u AdminUser
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.email, u.name, u.role
FROM admin_sessions s JOIN admin_users u ON u.id = s.user_id
WHERE s.token = $1 AND s.expires_at > now() AND u.active`, token).
Scan(&u.ID, &u.Email, &u.Name, &u.Role)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &u, nil
}
// Audit пишет действие в журнал; ошибки журнала не должны ломать запрос.
func (s *Storage) Audit(ctx context.Context, userID int64, action, entity string, entityID int64, ip string) {
var ipVal, idVal any
if ip != "" {
ipVal = ip
}
if entityID != 0 {
idVal = entityID
}
_, _ = s.pool.Exec(ctx,
`INSERT INTO audit_log (user_id, action, entity, entity_id, ip) VALUES ($1,$2,$3,$4,$5)`,
userID, action, entity, idVal, ipVal)
}