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>
146 lines
3.6 KiB
Go
146 lines
3.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"tyto/internal/database"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
// ContextKeyUser is the key for the authenticated user in gin.Context.
|
|
ContextKeyUser = "auth_user"
|
|
// ContextKeySession is the key for the session in gin.Context.
|
|
ContextKeySession = "auth_session"
|
|
)
|
|
|
|
// Middleware provides authentication middleware for Gin.
|
|
type Middleware struct {
|
|
auth *Service
|
|
}
|
|
|
|
// NewMiddleware creates a new authentication middleware.
|
|
func NewMiddleware(auth *Service) *Middleware {
|
|
return &Middleware{auth: auth}
|
|
}
|
|
|
|
// Required returns middleware that requires authentication.
|
|
// Requests without valid authentication are rejected with 401.
|
|
func (m *Middleware) Required() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
session, user, err := m.authenticate(c)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
"error": "authentication required",
|
|
})
|
|
return
|
|
}
|
|
|
|
c.Set(ContextKeyUser, user)
|
|
c.Set(ContextKeySession, session)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// Optional returns middleware that attempts authentication but doesn't require it.
|
|
// Unauthenticated requests are allowed to proceed.
|
|
func (m *Middleware) Optional() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
session, user, err := m.authenticate(c)
|
|
if err == nil {
|
|
c.Set(ContextKeyUser, user)
|
|
c.Set(ContextKeySession, session)
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// authenticate extracts and validates the session token.
|
|
func (m *Middleware) authenticate(c *gin.Context) (*database.Session, *database.User, error) {
|
|
token := m.extractToken(c)
|
|
if token == "" {
|
|
return nil, nil, ErrInvalidToken
|
|
}
|
|
|
|
return m.auth.ValidateSession(c.Request.Context(), token)
|
|
}
|
|
|
|
// extractToken gets the session token from the request.
|
|
// Checks Authorization header first, then cookies.
|
|
func (m *Middleware) extractToken(c *gin.Context) string {
|
|
// Check Authorization header: "Bearer <token>"
|
|
auth := c.GetHeader("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
|
|
// Check cookie
|
|
if cookie, err := c.Cookie(m.auth.config.SessionCookieName); err == nil {
|
|
return cookie
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// GetUser returns the authenticated user from the context.
|
|
// Returns nil if not authenticated.
|
|
func GetUser(c *gin.Context) *database.User {
|
|
user, exists := c.Get(ContextKeyUser)
|
|
if !exists {
|
|
return nil
|
|
}
|
|
return user.(*database.User)
|
|
}
|
|
|
|
// GetSession returns the session from the context.
|
|
// Returns nil if not authenticated.
|
|
func GetSession(c *gin.Context) *database.Session {
|
|
session, exists := c.Get(ContextKeySession)
|
|
if !exists {
|
|
return nil
|
|
}
|
|
return session.(*database.Session)
|
|
}
|
|
|
|
// MustGetUser returns the authenticated user from the context.
|
|
// Panics if not authenticated (use after Required middleware).
|
|
func MustGetUser(c *gin.Context) *database.User {
|
|
user := GetUser(c)
|
|
if user == nil {
|
|
panic("auth: MustGetUser called without authentication")
|
|
}
|
|
return user
|
|
}
|
|
|
|
// SetSessionCookie sets the session cookie on the response.
|
|
func (m *Middleware) SetSessionCookie(c *gin.Context, session *database.Session) {
|
|
maxAge := int(m.auth.config.SessionDuration.Seconds())
|
|
|
|
c.SetSameSite(http.SameSiteLaxMode)
|
|
c.SetCookie(
|
|
m.auth.config.SessionCookieName,
|
|
session.Token,
|
|
maxAge,
|
|
m.auth.config.SessionCookiePath,
|
|
"", // domain (empty = current domain)
|
|
m.auth.config.SessionCookieHTTPS,
|
|
true, // httpOnly
|
|
)
|
|
}
|
|
|
|
// ClearSessionCookie removes the session cookie.
|
|
func (m *Middleware) ClearSessionCookie(c *gin.Context) {
|
|
c.SetSameSite(http.SameSiteLaxMode)
|
|
c.SetCookie(
|
|
m.auth.config.SessionCookieName,
|
|
"",
|
|
-1, // MaxAge -1 = delete
|
|
m.auth.config.SessionCookiePath,
|
|
"",
|
|
m.auth.config.SessionCookieHTTPS,
|
|
true,
|
|
)
|
|
}
|