Files
silo-server/internal/auth/service.go

594 lines
17 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/google/uuid"
)
// Sentinel errors for service operations.
var (
ErrSessionRevoked = errors.New("session has been revoked")
ErrSetupAlreadyComplete = errors.New("initial setup already complete")
ErrSignupDisabled = errors.New("public signups are not enabled")
ErrImpersonationNotAllowed = errors.New("impersonation not allowed")
ErrAlreadyImpersonating = errors.New("already impersonating")
ErrNotImpersonating = errors.New("not impersonating")
)
// TokenPair holds the access and refresh tokens returned after login or refresh.
type TokenPair struct {
AccessToken string
RefreshToken string
ExpiresIn int // seconds until access token expires
}
// SettingsGetter retrieves server settings by key.
// Implemented by catalog.ServerSettingsRepo.
type SettingsGetter interface {
Get(ctx context.Context, key string) (string, error)
}
type claimsContextKey struct{}
// WithClaims stores auth claims on the context for auth-owned flows.
func WithClaims(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, claimsContextKey{}, claims)
}
// ClaimsFromContext retrieves auth claims previously stored with WithClaims.
func ClaimsFromContext(ctx context.Context) *Claims {
claims, _ := ctx.Value(claimsContextKey{}).(*Claims)
return claims
}
// Service orchestrates authentication operations using an AuthProvider,
// JWTService, and session/user repositories.
type Service struct {
provider AuthProvider
jwt *JWTService
sessions *SessionRepository
users *UserRepository
inviteCodes *InviteCodeRepository
settings SettingsGetter
providers map[string]AuthProvider
metadata map[string]LoginProviderInfo
defaultID string
accounts *AccountProvisioner
}
type LoginProviderInfo struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Mode string `json:"mode"`
Default bool `json:"default"`
// IconURL is rendered next to the "Sign in with X" button. Set for
// auth_provider.v1 plugins that ship an icon (icon_url manifest field).
IconURL string `json:"icon_url,omitempty"`
// InstallationID is non-zero when the provider is backed by a plugin.
// The login UI uses it to build /api/v1/auth/oauth/{install_id}/init URLs.
InstallationID int `json:"installation_id,omitempty"`
}
type RegisteredProvider struct {
Info LoginProviderInfo
Provider AuthProvider
}
// NewService creates a new auth Service with the given dependencies.
func NewService(
provider AuthProvider,
jwt *JWTService,
sessions *SessionRepository,
users *UserRepository,
inviteCodes *InviteCodeRepository,
settings SettingsGetter,
storeProvider userstore.UserStoreProvider,
) *Service {
service := &Service{
provider: provider,
jwt: jwt,
sessions: sessions,
users: users,
inviteCodes: inviteCodes,
settings: settings,
providers: map[string]AuthProvider{},
metadata: map[string]LoginProviderInfo{},
accounts: NewAccountProvisioner(users, storeProvider),
}
if provider != nil {
service.RegisterProvider(LoginProviderInfo{
ID: "local",
DisplayName: "Local",
Mode: "credentials",
Default: true,
}, provider)
}
return service
}
// Login authenticates the user with the given credentials and creates a new
// session. Returns a TokenPair containing the access and refresh tokens.
func (s *Service) Login(ctx context.Context, username, password, deviceName, ip string) (*TokenPair, *models.User, error) {
return s.loginWithProvider(ctx, "local", username, password, deviceName, ip)
}
func (s *Service) LoginWithProvider(
ctx context.Context,
providerID string,
username string,
password string,
deviceName string,
ip string,
) (*TokenPair, *models.User, error) {
if providerID == "" {
providerID = s.defaultID
}
return s.loginWithProvider(ctx, providerID, username, password, deviceName, ip)
}
func (s *Service) RegisterProvider(info LoginProviderInfo, provider AuthProvider) {
if provider == nil || info.ID == "" {
return
}
if info.DisplayName == "" {
info.DisplayName = info.ID
}
if info.Mode == "" {
info.Mode = "credentials"
}
s.providers[info.ID] = provider
s.metadata[info.ID] = info
if s.defaultID == "" || info.Default {
s.defaultID = info.ID
}
}
// FindOAuthInstallation returns the PluginProvider registered for the given
// plugin installation, if it is an OAuth-capable provider. nil if no match.
func (s *Service) FindOAuthInstallation(installationID int) *PluginProvider {
if installationID <= 0 {
return nil
}
for _, p := range s.providers {
pp, ok := p.(*PluginProvider)
if !ok || pp == nil {
continue
}
if pp.InstallationID() != installationID {
continue
}
// Only OAuth-capable installs participate in /oauth/... routes. The
// caller (OAuthHandler.ResolveClient) checks Mode metadata; here we
// simply scope to PluginProvider instances bound to this install.
return pp
}
return nil
}
// CompleteOAuthLogin runs the post-ExchangeCode half of login: the handler
// has already called the plugin's ExchangeCode RPC and is passing the
// AuthenticateResponse back. Service finds the matching PluginProvider,
// looks up or auto-provisions the user, creates a session, and mints
// access/refresh tokens.
func (s *Service) CompleteOAuthLogin(ctx context.Context, in OAuthLoginInput) (*TokenPair, *models.User, error) {
provider := s.FindOAuthInstallation(in.InstallationID)
if provider == nil {
return nil, nil, ErrInvalidCredentials
}
user, err := provider.CompleteOAuth(ctx, in.Response)
if err != nil {
return nil, nil, err
}
// Linking flow (sess.LinkingUserID > 0): we already provisioned/identified
// `user` via the plugin identity. If the caller asked to link onto a
// different existing user, future work will need to:
// - reject if the identity is already linked elsewhere (409)
// - otherwise upsert plugin_auth_identities to point at LinkingUserID
// For v1 the OAuth handler always passes 0; the v1 PR doesn't add the
// /me/account "Link account" SPA UI. Leaving as a TODO.
_ = in.LinkingUserID
sessionID := uuid.New().String()
session := models.AuthSession{
ID: sessionID,
UserID: user.ID,
DeviceName: in.DeviceName,
IPAddress: in.IP,
ExpiresAt: time.Now().Add(s.jwt.RefreshExpiry()),
}
if err := s.sessions.Create(ctx, session); err != nil {
return nil, nil, fmt.Errorf("creating session: %w", err)
}
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
return nil, nil, err
}
return pair, user, nil
}
func (s *Service) ListProviders() []LoginProviderInfo {
providers := make([]LoginProviderInfo, 0, len(s.metadata))
for _, info := range s.metadata {
info.Default = info.ID == s.defaultID
providers = append(providers, info)
}
sort.Slice(providers, func(i, j int) bool {
if providers[i].Default != providers[j].Default {
return providers[i].Default
}
return providers[i].DisplayName < providers[j].DisplayName
})
return providers
}
func (s *Service) loginWithProvider(
ctx context.Context,
providerID string,
username string,
password string,
deviceName string,
ip string,
) (*TokenPair, *models.User, error) {
provider := s.providers[providerID]
if provider == nil {
return nil, nil, ErrInvalidCredentials
}
user, err := provider.Authenticate(ctx, Credentials{
Username: username,
Password: password,
})
if err != nil {
return nil, nil, err
}
// Create a new session with a pre-generated ID to avoid the race condition
// of looking up the session after creation.
sessionID := uuid.New().String()
session := models.AuthSession{
ID: sessionID,
UserID: user.ID,
DeviceName: deviceName,
IPAddress: ip,
ExpiresAt: time.Now().Add(s.jwt.RefreshExpiry()),
}
if err := s.sessions.Create(ctx, session); err != nil {
return nil, nil, fmt.Errorf("creating session: %w", err)
}
pair, err := s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: sessionID,
})
if err != nil {
return nil, nil, err
}
return pair, user, nil
}
// NeedsSetup reports whether the system still needs its initial user account.
func (s *Service) NeedsSetup(ctx context.Context) (bool, error) {
count, err := s.users.Count(ctx)
if err != nil {
return false, fmt.Errorf("counting users: %w", err)
}
return count == 0, nil
}
// SetupInitialUser creates the first admin account and signs it in.
func (s *Service) SetupInitialUser(
ctx context.Context,
username, email, password string,
createDefaultProfile bool,
defaultProfileName string,
deviceName, ip string,
) (*TokenPair, *models.User, error) {
needsSetup, err := s.NeedsSetup(ctx)
if err != nil {
return nil, nil, err
}
if !needsSetup {
return nil, nil, ErrSetupAlreadyComplete
}
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "admin",
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
Name: defaultProfileName,
},
}); err != nil {
return nil, nil, fmt.Errorf("creating initial user: %w", err)
}
// Reuse the standard login flow so setup creates a normal session pair.
return s.Login(ctx, username, password, deviceName, ip)
}
// Signup creates a new user account using an invite code. Requires that
// public signups are enabled via the "signup.enabled" server setting.
func (s *Service) Signup(
ctx context.Context,
username, email, password, code string,
createDefaultProfile bool,
defaultProfileName string,
deviceName, ip string,
) (*TokenPair, *models.User, error) {
// Check global signup toggle.
if s.settings != nil {
enabled, err := s.settings.Get(ctx, "signup.enabled")
if err != nil {
return nil, nil, fmt.Errorf("checking signup setting: %w", err)
}
if enabled != "true" {
return nil, nil, ErrSignupDisabled
}
} else {
return nil, nil, ErrSignupDisabled
}
// Redeem the invite code (atomic increment).
if err := s.inviteCodes.RedeemCode(ctx, code); err != nil {
return nil, nil, err
}
// Create the user with standard role and access to all libraries.
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Username: username,
Email: email,
Password: password,
Role: "user",
},
DefaultProfile: DefaultProfileOptions{
Enabled: createDefaultProfile,
Name: defaultProfileName,
},
}); err != nil {
return nil, nil, fmt.Errorf("creating user: %w", err)
}
// Log them in to create a session and return tokens.
return s.Login(ctx, username, password, deviceName, ip)
}
// IsSignupEnabled reports whether public signups are enabled.
func (s *Service) IsSignupEnabled(ctx context.Context) (bool, error) {
if s.settings == nil {
return false, nil
}
enabled, err := s.settings.Get(ctx, "signup.enabled")
if err != nil {
return false, fmt.Errorf("checking signup setting: %w", err)
}
return enabled == "true", nil
}
// Logout revokes the session identified by sessionID.
func (s *Service) Logout(ctx context.Context, sessionID string) error {
return s.sessions.Revoke(ctx, sessionID)
}
// StartImpersonation creates a new target-user session with admin provenance.
func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUserID int, deviceName, ip string) (*TokenPair, *models.User, *models.User, error) {
if claims := ClaimsFromContext(ctx); claims != nil {
if claims.TokenType == TokenTypeAPIKey || claims.SessionID == "" {
return nil, nil, nil, ErrImpersonationNotAllowed
}
currentSession, err := s.sessions.GetByID(ctx, claims.SessionID)
if err != nil {
if !IsSessionNotFound(err) {
return nil, nil, nil, fmt.Errorf("getting current session: %w", err)
}
} else if currentSession.ImpersonatorUserID != nil {
return nil, nil, nil, ErrAlreadyImpersonating
}
}
admin, err := s.users.GetByID(ctx, adminUserID)
if err != nil {
if IsNotFound(err) {
return nil, nil, nil, ErrImpersonationNotAllowed
}
return nil, nil, nil, fmt.Errorf("getting admin user: %w", err)
}
if admin.Role != "admin" || !admin.Enabled {
return nil, nil, nil, ErrImpersonationNotAllowed
}
if adminUserID == targetUserID {
return nil, nil, nil, ErrImpersonationNotAllowed
}
target, err := s.users.GetByID(ctx, targetUserID)
if err != nil {
return nil, nil, nil, fmt.Errorf("getting target user: %w", err)
}
if !target.Enabled || target.Role == "admin" {
return nil, nil, nil, ErrImpersonationNotAllowed
}
sessionID := uuid.New().String()
impersonatorUserID := admin.ID
startedAt := time.Now()
session := models.AuthSession{
ID: sessionID,
UserID: target.ID,
DeviceName: deviceName,
IPAddress: ip,
ExpiresAt: startedAt.Add(s.jwt.RefreshExpiry()),
ImpersonatorUserID: &impersonatorUserID,
ImpersonationStartedAt: &startedAt,
}
if err := s.sessions.Create(ctx, session); err != nil {
return nil, nil, nil, fmt.Errorf("creating session: %w", err)
}
pair, err := s.generateTokenPair(Claims{
UserID: target.ID,
Role: target.Role,
SessionID: sessionID,
ImpersonatorUserID: &impersonatorUserID,
})
if err != nil {
return nil, nil, nil, err
}
return pair, admin, target, nil
}
// EndImpersonation revokes an impersonated session without affecting the original admin session.
func (s *Service) EndImpersonation(ctx context.Context, sessionID string, impersonatorUserID int) error {
session, err := s.sessions.GetByID(ctx, sessionID)
if err != nil {
return err
}
if session.ImpersonatorUserID == nil {
return ErrNotImpersonating
}
if *session.ImpersonatorUserID != impersonatorUserID {
return ErrImpersonationNotAllowed
}
return s.sessions.Revoke(ctx, sessionID)
}
// Refresh validates the refresh token, checks that the associated session is
// still valid, and issues a new token pair.
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
claims, err := s.jwt.ValidateToken(refreshToken)
if err != nil {
return nil, fmt.Errorf("invalid refresh token: %w", err)
}
if claims.TokenType != TokenTypeRefresh {
return nil, fmt.Errorf("invalid refresh token: %w", ErrInvalidToken)
}
session, err := s.sessions.GetByID(ctx, claims.SessionID)
if err != nil {
if IsSessionNotFound(err) {
return nil, ErrSessionRevoked
}
return nil, fmt.Errorf("getting session: %w", err)
}
if session.RevokedAt != nil || !session.ExpiresAt.After(time.Now()) {
return nil, ErrSessionRevoked
}
user, err := s.users.GetByID(ctx, session.UserID)
if err != nil {
if IsNotFound(err) {
return nil, ErrSessionRevoked
}
return nil, fmt.Errorf("getting user: %w", err)
}
if !user.Enabled {
return nil, ErrSessionRevoked
}
if err := s.validateImpersonator(ctx, session.ImpersonatorUserID); err != nil {
return nil, err
}
// Slide the session window forward so an active client never hits the
// hard expires_at set at login. A failure here is non-fatal: the refresh
// still returns fresh tokens; the session just keeps its prior expiry.
newExpiry := time.Now().Add(s.jwt.RefreshExpiry())
if err := s.sessions.ExtendExpiresAt(ctx, session.ID, newExpiry); err != nil && !IsSessionNotFound(err) {
return nil, fmt.Errorf("extending session: %w", err)
}
return s.generateTokenPair(Claims{
UserID: user.ID,
Role: user.Role,
SessionID: session.ID,
ImpersonatorUserID: session.ImpersonatorUserID,
})
}
func (s *Service) validateImpersonator(ctx context.Context, impersonatorUserID *int) error {
if impersonatorUserID == nil {
return nil
}
impersonator, err := s.users.GetByID(ctx, *impersonatorUserID)
if err != nil {
if IsNotFound(err) {
return ErrSessionRevoked
}
return fmt.Errorf("getting impersonator user: %w", err)
}
if !impersonator.Enabled || impersonator.Role != "admin" {
return ErrSessionRevoked
}
return nil
}
// GetCurrentUser retrieves the user associated with the given JWT claims.
func (s *Service) GetCurrentUser(ctx context.Context, claims *Claims) (*models.User, error) {
user, err := s.users.GetByID(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("getting user: %w", err)
}
return user, nil
}
// GetSessions returns all sessions for the given user ID.
func (s *Service) GetSessions(ctx context.Context, userID int) ([]*models.AuthSession, error) {
return s.sessions.ListByUser(ctx, userID)
}
// RevokeSession revokes a specific session. It verifies the session belongs
// to the given user before revoking.
func (s *Service) RevokeSession(ctx context.Context, sessionID string, userID int) error {
session, err := s.sessions.GetByID(ctx, sessionID)
if err != nil {
return err
}
if session.UserID != userID {
return ErrSessionNotFound
}
return s.sessions.Revoke(ctx, sessionID)
}
// generateTokenPair creates a new access/refresh token pair for the given
// claims.
func (s *Service) generateTokenPair(claims Claims) (*TokenPair, error) {
accessToken, err := s.jwt.generateAccessToken(claims)
if err != nil {
return nil, fmt.Errorf("generating access token: %w", err)
}
refreshToken, err := s.jwt.generateRefreshToken(claims)
if err != nil {
return nil, fmt.Errorf("generating refresh token: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(s.jwt.AccessExpiry().Seconds()),
}, nil
}