Files
TermorServer/internal/auth/service.go
benya f8880aa9a4 feat: add scan status and cover art endpoints
Track scanner status for the web API and Subsonic-compatible scan endpoints, add authenticated cover art serving, and wire album artwork into the web UI. Keep Subsonic auth limited to legacy password mode for now so behavior stays honest with the current bcrypt-based user storage.
2026-04-02 22:37:10 +03:00

227 lines
5.0 KiB
Go

package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUnauthorized = errors.New("unauthorized")
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt time.Time `json:"lastLoginAt"`
}
type Session struct {
Token string `json:"token"`
User User `json:"user"`
}
type Service struct {
db *sql.DB
}
func NewService(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) Login(ctx context.Context, username, password string) (Session, error) {
user, passwordHash, err := s.findUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrInvalidCredentials
}
return Session{}, fmt.Errorf("find user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return Session{}, ErrInvalidCredentials
}
token, err := newToken()
if err != nil {
return Session{}, fmt.Errorf("generate token: %w", err)
}
now := time.Now().UTC()
expiresAt := now.Add(30 * 24 * time.Hour)
if _, err := s.db.ExecContext(
ctx,
`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
token,
user.ID,
now.Format(time.RFC3339),
expiresAt.Format(time.RFC3339),
); err != nil {
return Session{}, fmt.Errorf("insert session: %w", err)
}
if _, err := s.db.ExecContext(
ctx,
`UPDATE users SET last_login_at = ? WHERE id = ?`,
now.Format(time.RFC3339),
user.ID,
); err == nil {
user.LastLoginAt = now
}
return Session{
Token: token,
User: user,
}, nil
}
func (s *Service) CurrentUser(ctx context.Context, authorizationHeader string) (User, error) {
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
return s.CurrentUserByToken(ctx, token)
}
func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, error) {
if token == "" {
return User{}, ErrUnauthorized
}
user, err := s.findUserByToken(ctx, token)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUnauthorized
}
return User{}, fmt.Errorf("find user by token: %w", err)
}
return user, nil
}
func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, password, token, salt string) (User, error) {
if username == "" {
return User{}, ErrUnauthorized
}
user, passwordHash, err := s.findUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUnauthorized
}
return User{}, fmt.Errorf("find user by username: %w", err)
}
if token != "" || salt != "" {
// We only support legacy `p` auth right now because password hashes are stored using bcrypt
// and cannot be converted back into the plain password needed for Subsonic token auth.
return User{}, ErrUnauthorized
}
if strings.HasPrefix(password, "enc:") {
decoded, err := hex.DecodeString(strings.TrimPrefix(password, "enc:"))
if err != nil {
return User{}, ErrUnauthorized
}
password = string(decoded)
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return User{}, ErrUnauthorized
}
return user, nil
}
func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) {
var user User
var passwordHash string
var createdAt string
var lastLoginAt sql.NullString
var isAdmin int
err := s.db.QueryRowContext(
ctx,
`SELECT id, username, password_hash, is_admin, created_at, last_login_at
FROM users
WHERE username = ?`,
username,
).Scan(
&user.ID,
&user.Username,
&passwordHash,
&isAdmin,
&createdAt,
&lastLoginAt,
)
if err != nil {
return User{}, "", err
}
user.IsAdmin = isAdmin == 1
user.CreatedAt = parseTime(createdAt)
if lastLoginAt.Valid {
user.LastLoginAt = parseTime(lastLoginAt.String)
}
return user, passwordHash, nil
}
func (s *Service) findUserByToken(ctx context.Context, token string) (User, error) {
var user User
var createdAt string
var lastLoginAt sql.NullString
var isAdmin int
err := s.db.QueryRowContext(
ctx,
`SELECT u.id, u.username, u.is_admin, u.created_at, u.last_login_at
FROM users u
JOIN sessions s ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > ?`,
token,
time.Now().UTC().Format(time.RFC3339),
).Scan(
&user.ID,
&user.Username,
&isAdmin,
&createdAt,
&lastLoginAt,
)
if err != nil {
return User{}, err
}
user.IsAdmin = isAdmin == 1
user.CreatedAt = parseTime(createdAt)
if lastLoginAt.Valid {
user.LastLoginAt = parseTime(lastLoginAt.String)
}
return user, nil
}
func parseTime(raw string) time.Time {
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return parsed
}
func newToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}