package auth import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "github.com/archivmail/internal/userstore" ) // Session holds the claims extracted from a validated JWT. type Session struct { UserID int64 Username string Role string JTI string // unique JWT ID } // Manager handles login, token issuance, validation, and logout. type Manager struct { store *userstore.Store ldap interface{} // placeholder for LDAP provider jwtSecret []byte } // New creates a new auth Manager. func New(store *userstore.Store, ldap interface{}, jwtSecret string) *Manager { return &Manager{ store: store, ldap: ldap, jwtSecret: []byte(jwtSecret), } } // Login verifies credentials and returns a signed JWT token. func (m *Manager) Login(username, password string) (string, *userstore.User, error) { user, err := m.store.VerifyPassword(username, password) if err != nil { return "", nil, fmt.Errorf("auth: login: %w", err) } jti := generateJTI() now := time.Now() claims := jwt.MapClaims{ "sub": user.Username, "role": user.Role, "uid": user.ID, "jti": jti, "iat": now.Unix(), "exp": now.Add(8 * time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(m.jwtSecret) if err != nil { return "", nil, fmt.Errorf("auth: sign token: %w", err) } return signed, user, nil } // ValidateToken parses and validates the token, checking the blacklist. func (m *Manager) ValidateToken(tokenStr string) (*Session, error) { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"]) } return m.jwtSecret, nil }) if err != nil { return nil, fmt.Errorf("auth: invalid token: %w", err) } if !token.Valid { return nil, errors.New("auth: token not valid") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("auth: bad claims") } jti, _ := claims["jti"].(string) blacklisted, err := m.store.IsBlacklisted(jti) if err != nil { return nil, fmt.Errorf("auth: blacklist check: %w", err) } if blacklisted { return nil, errors.New("auth: token revoked") } username, _ := claims["sub"].(string) role, _ := claims["role"].(string) var userID int64 switch v := claims["uid"].(type) { case float64: userID = int64(v) case int64: userID = v } return &Session{ UserID: userID, Username: username, Role: role, JTI: jti, }, nil } // Logout revokes the token by adding its JTI to the blacklist. func (m *Manager) Logout(tokenStr string) error { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("auth: unexpected signing method") } return m.jwtSecret, nil }) if err != nil { return fmt.Errorf("auth: logout parse: %w", err) } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return errors.New("auth: bad claims on logout") } jti, _ := claims["jti"].(string) var exp time.Time switch v := claims["exp"].(type) { case float64: exp = time.Unix(int64(v), 0) case int64: exp = time.Unix(v, 0) default: exp = time.Now().Add(8 * time.Hour) } return m.store.BlacklistToken(jti, exp) } // HasRole returns true when userRole satisfies the required role level. // Hierarchy: admin > auditor > user func HasRole(userRole, required string) bool { levels := map[string]int{ userstore.RoleUser: 1, userstore.RoleAuditor: 2, userstore.RoleAdmin: 3, } return levels[userRole] >= levels[required] } // generateJTI returns a pseudo-unique identifier for a JWT. func generateJTI() string { return fmt.Sprintf("%d-%x", time.Now().UnixNano(), time.Now().UnixNano()^0xdeadbeef) }