// Package service — authentication / user management. package service import ( "context" "errors" "fmt" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) // AuthService handles registration, login, and JWT issuance. type AuthService struct { cfg *config.Config log *zap.Logger repo *repository.Container } // NewAuthService is the constructor. func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *AuthService { return &AuthService{cfg: cfg, log: log, repo: repo} } // Common service-level errors. var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUsernameTaken = errors.New("username already taken") ) // SeedAdmin makes sure at least one admin user exists. It mirrors the // MediaStation behaviour: if no admin row is found we create // `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn. func (s *AuthService) SeedAdmin(ctx context.Context) error { n, err := s.repo.User.CountAdmins(ctx) if err != nil { return err } if n > 0 { return nil } pwd := os.Getenv("ADMIN_INITIAL_PASSWORD") if pwd == "" { pwd = "admin123" } hash, err := hashPassword(pwd) if err != nil { return err } user := &model.User{ Username: "admin", PasswordHash: hash, Role: "admin", ForcePasswordReset: pwd == "admin123", } if err := s.repo.User.Create(ctx, user); err != nil { return err } s.log.Warn("default admin created — change the password after first login", zap.String("username", "admin"), zap.String("password_source", "ADMIN_INITIAL_PASSWORD or admin123"), ) return nil } // Register creates a new user. The first registered user is auto-promoted to // admin to support fresh installs that did not run SeedAdmin. func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, error) { username = strings.TrimSpace(username) if username == "" || password == "" { return nil, fmt.Errorf("username and password required") } if existing, err := s.repo.User.FindByUsername(ctx, username); err != nil { return nil, err } else if existing != nil { return nil, ErrUsernameTaken } hash, err := hashPassword(password) if err != nil { return nil, err } role := "user" if n, err := s.repo.User.CountAdmins(ctx); err == nil && n == 0 { role = "admin" } u := &model.User{Username: username, PasswordHash: hash, Role: role} if err := s.repo.User.Create(ctx, u); err != nil { return nil, err } return u, nil } // Login validates credentials and returns the user + a fresh JWT. func (s *AuthService) Login(ctx context.Context, username, password string) (*model.User, string, error) { u, err := s.repo.User.FindByUsername(ctx, username) if err != nil { return nil, "", err } if u == nil { return nil, "", ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { return nil, "", ErrInvalidCredentials } token, err := s.IssueToken(u) if err != nil { return nil, "", err } _ = s.repo.User.TouchLogin(ctx, u.ID) return u, token, nil } // ChangePassword updates the user password if the old one matches. func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error { u, err := s.repo.User.FindByID(ctx, userID) if err != nil { return err } if u == nil { return ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPwd)); err != nil { return ErrInvalidCredentials } hash, err := hashPassword(newPwd) if err != nil { return err } return s.repo.User.UpdatePassword(ctx, userID, hash) } // IssueToken signs a JWT for the given user (24h validity). func (s *AuthService) IssueToken(u *model.User) (string, error) { claims := middleware.Claims{ UserID: u.ID, Role: u.Role, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), Issuer: "mediastationgo", Subject: u.ID, }, } t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) } func hashPassword(p string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) if err != nil { return "", err } return string(h), nil }