121 lines
3.5 KiB
Go
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)
|
|
}
|