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.
227 lines
5.0 KiB
Go
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
|
|
}
|