Files
tyto/backend/internal/auth/api.go
vikingowl 50c5811e22 feat: add authentication system with local and LDAP support
Auth Package (internal/auth/):
- Service: main auth orchestrator with multi-provider support
- LocalProvider: username/password auth with bcrypt hashing
- LDAPProvider: LDAP/Active Directory authentication with:
  - Service account bind for user search
  - User bind for password verification
  - Automatic user provisioning on first login
  - Group membership to role synchronization
- SessionManager: token-based session lifecycle
- Middleware: Gin middleware for route protection
- API: REST endpoints for login/logout/register

Security Features:
- bcrypt with cost factor 12 for password hashing
- Secure random 32-byte session tokens
- HTTP-only session cookies with SameSite=Lax
- Bearer token support for API clients
- Session expiration and cleanup
- Account disable with session invalidation

API Endpoints:
- POST /auth/login - Authenticate and get session
- POST /auth/logout - Invalidate current session
- POST /auth/logout/all - Invalidate all user sessions
- POST /auth/register - Create account (if enabled)
- GET /auth/me - Get current user info
- PUT /auth/me - Update profile
- PUT /auth/me/password - Change password

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 08:24:39 +01:00

284 lines
7.2 KiB
Go

package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
// API provides HTTP handlers for authentication endpoints.
type API struct {
auth *Service
middleware *Middleware
}
// NewAPI creates a new auth API handler.
func NewAPI(auth *Service) *API {
return &API{
auth: auth,
middleware: NewMiddleware(auth),
}
}
// RegisterRoutes registers auth routes on a router group.
func (a *API) RegisterRoutes(group *gin.RouterGroup) {
// Public routes
group.POST("/login", a.handleLogin)
group.POST("/logout", a.handleLogout)
if a.auth.config.AllowRegistration {
group.POST("/register", a.handleRegister)
}
// Authenticated routes
authenticated := group.Group("")
authenticated.Use(a.middleware.Required())
{
authenticated.GET("/me", a.handleGetCurrentUser)
authenticated.PUT("/me", a.handleUpdateCurrentUser)
authenticated.PUT("/me/password", a.handleChangePassword)
authenticated.POST("/logout/all", a.handleLogoutAll)
}
}
// Middleware returns the auth middleware for use in other routes.
func (a *API) Middleware() *Middleware {
return a.middleware
}
// LoginRequest represents a login request.
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse represents a successful login response.
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expiresAt"`
User UserResponse `json:"user"`
}
// UserResponse represents user info in API responses.
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
AuthProvider string `json:"authProvider"`
Roles []string `json:"roles,omitempty"`
CreatedAt string `json:"createdAt"`
LastLogin string `json:"lastLogin,omitempty"`
}
func (a *API) handleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
session, user, err := a.auth.Login(
c.Request.Context(),
req.Username,
req.Password,
c.ClientIP(),
c.GetHeader("User-Agent"),
)
if err != nil {
status := http.StatusUnauthorized
message := "invalid credentials"
if errors.Is(err, ErrUserDisabled) {
message = "account is disabled"
}
c.JSON(status, gin.H{"error": message})
return
}
// Set session cookie
a.middleware.SetSessionCookie(c, session)
// Get user roles
roles, _ := a.auth.db.GetUserRoles(c.Request.Context(), user.ID)
roleNames := make([]string, len(roles))
for i, r := range roles {
roleNames[i] = r.Name
}
c.JSON(http.StatusOK, LoginResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Format("2006-01-02T15:04:05Z"),
User: UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
AuthProvider: string(user.AuthProvider),
Roles: roleNames,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
LastLogin: user.LastLogin.Format("2006-01-02T15:04:05Z"),
},
})
}
func (a *API) handleLogout(c *gin.Context) {
// Try to get token from request
token := ""
// Check Authorization header
if auth := c.GetHeader("Authorization"); len(auth) > 7 && auth[:7] == "Bearer " {
token = auth[7:]
}
// Check cookie
if token == "" {
if cookie, err := c.Cookie(a.auth.config.SessionCookieName); err == nil {
token = cookie
}
}
if token != "" {
a.auth.Logout(c.Request.Context(), token)
}
// Clear session cookie
a.middleware.ClearSessionCookie(c)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (a *API) handleLogoutAll(c *gin.Context) {
user := MustGetUser(c)
if err := a.auth.LogoutAll(c.Request.Context(), user.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to logout"})
return
}
// Clear session cookie
a.middleware.ClearSessionCookie(c)
c.JSON(http.StatusOK, gin.H{"message": "all sessions logged out"})
}
// RegisterRequest represents a registration request.
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Password string `json:"password" binding:"required,min=8"`
Email string `json:"email" binding:"omitempty,email"`
}
func (a *API) handleRegister(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user, err := a.auth.Register(c.Request.Context(), req.Username, req.Password, req.Email)
if err != nil {
status := http.StatusInternalServerError
message := "registration failed"
if errors.Is(err, ErrUsernameExists) {
status = http.StatusConflict
message = "username already exists"
}
c.JSON(status, gin.H{"error": message})
return
}
c.JSON(http.StatusCreated, UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
AuthProvider: string(user.AuthProvider),
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
func (a *API) handleGetCurrentUser(c *gin.Context) {
user := MustGetUser(c)
// Get user roles
roles, _ := a.auth.db.GetUserRoles(c.Request.Context(), user.ID)
roleNames := make([]string, len(roles))
for i, r := range roles {
roleNames[i] = r.Name
}
lastLogin := ""
if !user.LastLogin.IsZero() {
lastLogin = user.LastLogin.Format("2006-01-02T15:04:05Z")
}
c.JSON(http.StatusOK, UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
AuthProvider: string(user.AuthProvider),
Roles: roleNames,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
LastLogin: lastLogin,
})
}
// UpdateUserRequest represents a profile update request.
type UpdateUserRequest struct {
Email string `json:"email" binding:"omitempty,email"`
}
func (a *API) handleUpdateCurrentUser(c *gin.Context) {
user := MustGetUser(c)
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user.Email = req.Email
if err := a.auth.UpdateUser(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
// ChangePasswordRequest represents a password change request.
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=8"`
}
func (a *API) handleChangePassword(c *gin.Context) {
user := MustGetUser(c)
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
err := a.auth.ChangePassword(c.Request.Context(), user.ID, req.CurrentPassword, req.NewPassword)
if err != nil {
status := http.StatusInternalServerError
message := "failed to change password"
if errors.Is(err, ErrInvalidCredentials) {
status = http.StatusUnauthorized
message = "current password is incorrect"
}
c.JSON(status, gin.H{"error": message})
return
}
c.JSON(http.StatusOK, gin.H{"message": "password changed"})
}