Files
lti-api/internal/middleware/auth.go
T
2025-12-30 12:08:49 +07:00

268 lines
7.4 KiB
Go

package middleware
import (
"strings"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
const (
authContextLocalsKey = "auth.context"
authUserLocalsKey = "auth.user"
)
// AuthContext keeps authentication details captured by the middleware.
type AuthContext struct {
Token string
Verification *sso.VerificationResult
User *entity.User
Roles []sso.Role
Permissions map[string]struct{}
}
// Auth validates the incoming request against the central SSO access token and
// loads the corresponding local user. Optional scopes can be provided to enforce
// fine-grained authorization using the SSO access token scopes.
func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
token := bearerToken(c)
if token == "" {
token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName))
}
if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
verification, err := sso.VerifyAccessToken(token)
if err != nil {
utils.Log.WithError(err).Warn("auth: token verification failed")
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
if verification.UserID == 0 {
return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint")
}
if err := ensureNotRevoked(c, token, verification); err != nil {
return err
}
user, err := userService.GetBySSOUserID(c, verification.UserID)
if err != nil || user == nil {
utils.Log.WithError(err).Warn("auth: failed to resolve user from repository")
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
if len(requiredScopes) > 0 {
if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) {
return fiber.NewError(fiber.StatusForbidden, "Insufficient scope")
}
}
var roles []sso.Role
permissions := make(map[string]struct{})
if verification.UserID != 0 {
if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else if profile != nil {
roles = profile.Roles
for _, perm := range profile.PermissionNames() {
if perm != "" {
permissions[perm] = struct{}{}
}
}
}
}
ctx := &AuthContext{
Token: token,
Verification: verification,
User: user,
Roles: roles,
Permissions: permissions,
}
c.Locals(authContextLocalsKey, ctx)
c.Locals(authUserLocalsKey, user)
return c.Next()
}
}
// AuthenticatedUser returns the authenticated user populated by Auth.
func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) {
value := c.Locals(authUserLocalsKey)
if user, ok := value.(*entity.User); ok && user != nil {
return user, true
}
return nil, false
}
func ActorIDFromContext(c *fiber.Ctx) (uint, error) {
user, ok := AuthenticatedUser(c)
if !ok || user == nil || user.Id == 0 {
return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
return user.Id, nil
}
// AuthDetails returns the full authentication context (token, claims, user).
func AuthDetails(c *fiber.Ctx) (*AuthContext, bool) {
value := c.Locals(authContextLocalsKey)
if ctx, ok := value.(*AuthContext); ok && ctx != nil {
return ctx, true
}
return nil, false
}
// ensureNotRevoked ensures the token is not revoked or superseded by a forced logout.
func ensureNotRevoked(c *fiber.Ctx, token string, verification *sso.VerificationResult) error {
revoker := session.GetRevocationStore()
if revoker == nil {
return nil
}
if fingerprint := session.TokenFingerprint(token); fingerprint != "" {
revoked, err := revoker.IsRevoked(c.Context(), fingerprint)
if err != nil {
utils.Log.WithError(err).Warn("auth: token revocation check failed")
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
if revoked {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
}
if verification.UserID == 0 {
return nil
}
logoutAt, err := revoker.UserLogoutTime(c.Context(), verification.UserID)
if err != nil {
utils.Log.WithError(err).Warn("auth: failed to load user logout marker")
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
if logoutAt.IsZero() {
return nil
}
claims := verification.Claims
if claims == nil || claims.IssuedAt == nil {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
issuedAt := claims.IssuedAt.Time
// Treat tokens issued at or before the forced logout timestamp as invalid.
if !issuedAt.After(logoutAt) {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
return nil
}
// bearerToken extracts a Bearer token from the Authorization header using
// case-insensitive scheme matching and tolerant whitespace handling.
func bearerToken(c *fiber.Ctx) string {
parts := strings.Fields(c.Get("Authorization"))
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
return strings.TrimSpace(parts[1])
}
return ""
}
func hasAllScopes(have, required []string) bool {
if len(required) == 0 {
return true
}
set := make(map[string]struct{}, len(have))
for _, s := range have {
s = strings.ToLower(strings.TrimSpace(s))
if s != "" {
set[s] = struct{}{}
}
}
for _, r := range required {
r = strings.ToLower(strings.TrimSpace(r))
if r == "" {
continue
}
if _, ok := set[r]; !ok {
return false
}
}
return true
}
// RequirePermissions ensures the authenticated user possesses all specified permissions.
func RequirePermissions(perms ...string) fiber.Handler {
required := canonicalPermissions(perms)
return func(c *fiber.Ctx) error {
if len(required) == 0 {
return c.Next()
}
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
userPerms := ctx.permissionSet()
if len(userPerms) == 0 {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
for _, perm := range required {
if _, has := userPerms[perm]; !has {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
}
return c.Next()
}
}
// HasPermission reports whether the current request context includes the given permission.
func HasPermission(c *fiber.Ctx, perm string) bool {
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return false
}
perm = canonicalPermission(perm)
if perm == "" {
return false
}
_, has := ctx.permissionSet()[perm]
return has
}
func (a *AuthContext) permissionSet() map[string]struct{} {
if a == nil || a.Permissions == nil {
return nil
}
return a.Permissions
}
func canonicalPermissions(perms []string) []string {
out := make([]string, 0, len(perms))
seen := make(map[string]struct{}, len(perms))
for _, perm := range perms {
if canonical := canonicalPermission(perm); canonical != "" {
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
}
return out
}
func canonicalPermission(perm string) string {
return strings.ToLower(strings.TrimSpace(perm))
}