diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 2d7a77a0..d91d17fb 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -96,9 +96,13 @@ func initializeHandlers(deps handlerDeps) handlerSet { deps.auditService, deps.cfg, ), - docs: handlers.NewDocsHandler(deps.templatesFS), - jwks: jwksHandler, - userAdmin: handlers.NewUserAdminHandler(deps.services.user, deps.services.token), + docs: handlers.NewDocsHandler(deps.templatesFS), + jwks: jwksHandler, + userAdmin: handlers.NewUserAdminHandler( + deps.services.user, + deps.services.token, + deps.services.authorization, + ), dashboard: handlers.NewDashboardHandler(deps.services.dashboard), tokenAdmin: handlers.NewTokenAdminHandler(deps.services.token), userService: deps.services.user, diff --git a/internal/bootstrap/router.go b/internal/bootstrap/router.go index 0c0a3064..45ff6d37 100644 --- a/internal/bootstrap/router.go +++ b/internal/bootstrap/router.go @@ -314,11 +314,19 @@ func setupAllRoutes( // User management routes admin.GET("/users", h.userAdmin.ShowUsersPage) + admin.GET("/users/new", h.userAdmin.ShowCreateUserPage) + admin.POST("/users", h.userAdmin.CreateUser) admin.GET("/users/:id", h.userAdmin.ViewUser) admin.GET("/users/:id/edit", h.userAdmin.ShowEditUserPage) admin.POST("/users/:id", h.userAdmin.UpdateUser) admin.POST("/users/:id/reset-password", h.userAdmin.ResetPassword) admin.POST("/users/:id/delete", h.userAdmin.DeleteUser) + admin.POST("/users/:id/disable", h.userAdmin.DisableUser) + admin.POST("/users/:id/enable", h.userAdmin.EnableUser) + admin.GET("/users/:id/connections", h.userAdmin.ShowUserConnections) + admin.POST("/users/:id/connections/:conn_id/delete", h.userAdmin.DeleteUserConnection) + admin.GET("/users/:id/authorizations", h.userAdmin.ShowUserAuthorizations) + admin.POST("/users/:id/authorizations/:uuid/revoke", h.userAdmin.RevokeUserAuthorization) // Token management routes admin.GET("/tokens", h.tokenAdmin.ShowTokensPage) diff --git a/internal/core/store.go b/internal/core/store.go index 7279209c..c8b9c2d0 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -133,6 +133,7 @@ type OAuthConnectionStore interface { CreateOAuthConnection(conn *models.OAuthConnection) error GetOAuthConnection(provider, providerUserID string) (*models.OAuthConnection, error) GetOAuthConnectionByUserAndProvider(userID, provider string) (*models.OAuthConnection, error) + GetOAuthConnectionByUserAndID(userID, connectionID string) (*models.OAuthConnection, error) GetOAuthConnectionsByUserID(userID string) ([]models.OAuthConnection, error) UpdateOAuthConnection(conn *models.OAuthConnection) error DeleteOAuthConnection(id string) error diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index e0316344..c4608b6d 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -132,9 +132,12 @@ func (h *AuthHandler) Login(c *gin.Context, var errorMsg string // Check for specific error types - if errors.Is(err, services.ErrUsernameConflict) { + switch { + case errors.Is(err, services.ErrAccountDisabled): + errorMsg = "Your account has been disabled. Please contact your administrator." + case errors.Is(err, services.ErrUsernameConflict): errorMsg = "Username conflict with existing user. Please contact administrator." - } else { + default: errorMsg = "Invalid username or password" } diff --git a/internal/handlers/oauth_handler.go b/internal/handlers/oauth_handler.go index 9ff9d8bf..8c24a10e 100644 --- a/internal/handlers/oauth_handler.go +++ b/internal/handlers/oauth_handler.go @@ -198,21 +198,26 @@ func (h *OAuthHandler) OAuthCallback(c *gin.Context) { log.Printf("[OAuth] Authentication failed: %v", err) // Handle specific errors - if errors.Is(err, services.ErrOAuthAutoRegisterDisabled) { + switch { + case errors.Is(err, services.ErrOAuthAutoRegisterDisabled): renderErrorPage( c, http.StatusForbidden, "Registration Disabled. New account registration via OAuth is currently disabled. Please contact your administrator.", ) - return + case errors.Is(err, services.ErrAccountDisabled): + renderErrorPage( + c, + http.StatusForbidden, + "Account Disabled. Your account has been disabled by an administrator. Please contact your administrator for assistance.", + ) + default: + renderErrorPage( + c, + http.StatusInternalServerError, + "Authentication failed. Unable to authenticate your account at this time. Please try again later.", + ) } - - // Generic error - renderErrorPage( - c, - http.StatusInternalServerError, - "Authentication failed. Unable to authenticate your account at this time. Please try again later.", - ) return } diff --git a/internal/handlers/user_admin.go b/internal/handlers/user_admin.go index 677f0d7b..c387bfb9 100644 --- a/internal/handlers/user_admin.go +++ b/internal/handlers/user_admin.go @@ -3,8 +3,8 @@ package handlers import ( "errors" "net/http" + "strings" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/go-authgate/authgate/internal/middleware" @@ -15,13 +15,22 @@ import ( // UserAdminHandler handles admin user management routes. type UserAdminHandler struct { - userService *services.UserService - tokenService *services.TokenService + userService *services.UserService + tokenService *services.TokenService + authorizationService *services.AuthorizationService } // NewUserAdminHandler creates a new UserAdminHandler. -func NewUserAdminHandler(us *services.UserService, ts *services.TokenService) *UserAdminHandler { - return &UserAdminHandler{userService: us, tokenService: ts} +func NewUserAdminHandler( + us *services.UserService, + ts *services.TokenService, + as *services.AuthorizationService, +) *UserAdminHandler { + return &UserAdminHandler{ + userService: us, + tokenService: ts, + authorizationService: as, + } } // adminGetUser fetches a user by ID and renders the appropriate error page on failure. @@ -58,20 +67,6 @@ func (h *UserAdminHandler) ShowUsersPage(c *gin.Context) { return } - // Retrieve flash messages - session := sessions.Default(c) - flashes := session.Flashes() - if err := session.Save(); err != nil { - c.Set("session_save_error", err) - } - - var successMsg string - if len(flashes) > 0 { - if msg, ok := flashes[0].(string); ok { - successMsg = msg - } - } - templates.RenderTempl(c, http.StatusOK, templates.AdminUsers(templates.UsersPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, NavbarProps: buildNavbarProps(c, user, "users"), @@ -80,7 +75,7 @@ func (h *UserAdminHandler) ShowUsersPage(c *gin.Context) { Pagination: pagination, Search: params.Search, PageSize: params.PageSize, - Success: successMsg, + Success: getFlashMessage(c), RoleFilter: params.StatusFilter, AuthSourceFilter: params.CategoryFilter, })) @@ -109,20 +104,6 @@ func (h *UserAdminHandler) ViewUser(c *gin.Context) { return } - // Retrieve flash messages - session := sessions.Default(c) - flashes := session.Flashes() - if err := session.Save(); err != nil { - c.Set("session_save_error", err) - } - - var successMsg string - if len(flashes) > 0 { - if msg, ok := flashes[0].(string); ok { - successMsg = msg - } - } - templates.RenderTempl(c, http.StatusOK, templates.AdminUserDetail(templates.UserDetailPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, NavbarProps: buildNavbarProps(c, currentUser, "users"), @@ -130,7 +111,7 @@ func (h *UserAdminHandler) ViewUser(c *gin.Context) { ActiveTokenCount: stats.ActiveTokenCount, OAuthConnectionCount: stats.OAuthConnectionCount, AuthorizationCount: stats.AuthorizationCount, - Success: successMsg, + Success: getFlashMessage(c), })) } @@ -200,13 +181,7 @@ func (h *UserAdminHandler) UpdateUser(c *gin.Context) { return } - session := sessions.Default(c) - session.AddFlash("User updated successfully.") - if err := session.Save(); err != nil { - c.Set("session_save_error", err) - } - - c.Redirect(http.StatusFound, "/admin/users/"+targetUser.ID) + flashAndRedirect(c, "User updated successfully.", "/admin/users/"+targetUser.ID) } // ResetPassword generates a new random password and displays it once. @@ -311,12 +286,309 @@ func (h *UserAdminHandler) DeleteUser(c *gin.Context) { return } - session := sessions.Default(c) - session.AddFlash("User deleted successfully") - if err := session.Save(); err != nil { - renderErrorPage(c, http.StatusInternalServerError, "Failed to save session") + flashAndRedirect(c, "User deleted successfully.", "/admin/users") +} + +// ── Create User ─────────────────────────────────────────────────────── + +// ShowCreateUserPage renders the user creation form. +func (h *UserAdminHandler) ShowCreateUserPage(c *gin.Context) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + templates.RenderTempl(c, http.StatusOK, templates.AdminUserCreate(templates.UserCreatePageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, currentUser, "users"), + Role: models.UserRoleUser, + })) +} + +// CreateUser handles the user creation form submission. +func (h *UserAdminHandler) CreateUser(c *gin.Context) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + req := services.CreateUserRequest{ + Username: strings.TrimSpace(c.PostForm("username")), + Email: strings.TrimSpace(c.PostForm("email")), + FullName: strings.TrimSpace(c.PostForm("full_name")), + Role: c.PostForm("role"), + Password: c.PostForm("password"), + } + + user, password, err := h.userService.CreateUserAdmin( + c.Request.Context(), + req, + currentUser.ID, + ) + if err != nil { + // Distinguish user-facing validation errors from internal failures + status := http.StatusBadRequest + errMsg := err.Error() + if !errors.Is(err, services.ErrUsernameRequired) && + !errors.Is(err, services.ErrEmailRequired) && + !errors.Is(err, services.ErrInvalidRole) && + !errors.Is(err, services.ErrUsernameConflict) && + !errors.Is(err, services.ErrEmailConflict) { + status = http.StatusInternalServerError + errMsg = "An internal error occurred. Please try again." + } + templates.RenderTempl( + c, + status, + templates.AdminUserCreate(templates.UserCreatePageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, currentUser, "users"), + Error: errMsg, + Username: req.Username, + Email: req.Email, + FullName: req.FullName, + Role: req.Role, + }), + ) + return + } + + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private") + c.Header("Pragma", "no-cache") + + templates.RenderTempl( + c, + http.StatusOK, + templates.AdminUserCreated(templates.UserCreatedPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, currentUser, "users"), + TargetUser: user, + NewPassword: password, + }), + ) +} + +// ── OAuth Connections ───────────────────────────────────────────────── + +// ShowUserConnections renders the user's OAuth connections page. +func (h *UserAdminHandler) ShowUserConnections(c *gin.Context) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + targetUser, ok := h.adminGetUser(c) + if !ok { + return + } + + conns, err := h.userService.GetUserOAuthConnections(targetUser.ID) + if err != nil { + renderErrorPage(c, http.StatusInternalServerError, "Failed to load OAuth connections") + return + } + + templates.RenderTempl( + c, + http.StatusOK, + templates.AdminUserConnections(templates.UserOAuthConnectionsPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, currentUser, "users"), + TargetUser: targetUser, + Connections: conns, + Success: getFlashMessage(c), + }), + ) +} + +// DeleteUserConnection handles unlinking an OAuth connection. +func (h *UserAdminHandler) DeleteUserConnection(c *gin.Context) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + userID := c.Param("id") + connID := c.Param("conn_id") + + if err := h.userService.DeleteUserOAuthConnection( + c.Request.Context(), + userID, + connID, + currentUser.ID, + ); err != nil { + if errors.Is(err, services.ErrOAuthConnectionNotFound) { + renderErrorPage(c, http.StatusNotFound, err.Error()) + } else { + renderErrorPage(c, http.StatusInternalServerError, "Failed to remove OAuth connection") + } + return + } + + flashAndRedirect( + c, + "OAuth connection removed successfully.", + "/admin/users/"+userID+"/connections", + ) +} + +// ── User Authorizations ─────────────────────────────────────────────── + +// ShowUserAuthorizations renders the user's authorized apps page. +func (h *UserAdminHandler) ShowUserAuthorizations(c *gin.Context) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + targetUser, ok := h.adminGetUser(c) + if !ok { return } - c.Redirect(http.StatusFound, "/admin/users") + auths, err := h.authorizationService.ListUserAuthorizations( + c.Request.Context(), + targetUser.ID, + ) + if err != nil { + renderErrorPage(c, http.StatusInternalServerError, "Failed to load authorizations") + return + } + + // Convert to display models + displayAuths := make([]templates.AuthorizationDisplay, 0, len(auths)) + for _, a := range auths { + displayAuths = append(displayAuths, templates.AuthorizationDisplay{ + UUID: a.UUID, + ClientID: a.ClientID, + ClientName: a.ClientName, + Scopes: a.Scopes, + GrantedAt: a.GrantedAt, + IsActive: a.IsActive, + }) + } + + templates.RenderTempl( + c, + http.StatusOK, + templates.AdminUserAuthorizations(templates.UserAuthorizationsPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, currentUser, "users"), + TargetUser: targetUser, + Authorizations: displayAuths, + Success: getFlashMessage(c), + }), + ) +} + +// RevokeUserAuthorization handles revoking a user's app authorization. +func (h *UserAdminHandler) RevokeUserAuthorization(c *gin.Context) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + userID := c.Param("id") + authUUID := c.Param("uuid") + + if err := h.authorizationService.RevokeUserAuthorization( + c.Request.Context(), + authUUID, + userID, + ); err != nil { + if errors.Is(err, services.ErrAuthorizationNotFound) { + renderErrorPage(c, http.StatusNotFound, err.Error()) + } else { + renderErrorPage(c, http.StatusInternalServerError, "Failed to revoke authorization") + } + return + } + + flashAndRedirect( + c, + "Authorization revoked successfully.", + "/admin/users/"+userID+"/authorizations", + ) +} + +// ── Disable/Enable User ─────────────────────────────────────────────── + +// DisableUser handles disabling a user account. +func (h *UserAdminHandler) DisableUser(c *gin.Context) { + h.toggleUserActive(c, false) +} + +// EnableUser handles enabling a user account. +func (h *UserAdminHandler) EnableUser(c *gin.Context) { + h.toggleUserActive(c, true) +} + +// toggleUserActive is the shared implementation for DisableUser and EnableUser. +func (h *UserAdminHandler) toggleUserActive(c *gin.Context, active bool) { + currentUser := getUserFromContext(c) + if currentUser == nil { + renderErrorPage(c, http.StatusUnauthorized, "Unauthorized") + return + } + + userID := c.Param("id") + + // Validate first so we don't revoke tokens if the operation will be rejected. + if err := h.userService.ValidateSetUserActiveStatus( + userID, + currentUser.ID, + active, + ); err != nil { + switch { + case errors.Is(err, services.ErrCannotDisableSelf), + errors.Is(err, services.ErrUserAlreadyActive), + errors.Is(err, services.ErrUserAlreadyDisabled), + errors.Is(err, services.ErrCannotRemoveLastAdmin): + renderErrorPage(c, http.StatusBadRequest, err.Error()) + case errors.Is(err, services.ErrUserNotFound): + renderErrorPage(c, http.StatusNotFound, "User not found") + default: + renderErrorPage( + c, + http.StatusInternalServerError, + "Failed to validate user status change", + ) + } + return + } + + // When disabling, revoke all tokens BEFORE changing status to close the + // window where a disabled user's tokens could still be valid. + if !active { + if err := h.tokenService.RevokeAllUserTokens(userID); err != nil { + renderErrorPage( + c, + http.StatusInternalServerError, + "Failed to revoke user tokens", + ) + return + } + } + + if err := h.userService.SetUserActiveStatus( + c.Request.Context(), + userID, + currentUser.ID, + active, + ); err != nil { + renderErrorPage(c, http.StatusInternalServerError, "Failed to update user status") + return + } + + msg := "User account has been enabled." + if !active { + msg = "User account has been disabled." + } + flashAndRedirect(c, msg, "/admin/users/"+userID) } diff --git a/internal/handlers/utils.go b/internal/handlers/utils.go index 76bc4a3a..2378636a 100644 --- a/internal/handlers/utils.go +++ b/internal/handlers/utils.go @@ -1,12 +1,14 @@ package handlers import ( + "net/http" "strconv" "github.com/go-authgate/authgate/internal/models" "github.com/go-authgate/authgate/internal/store" "github.com/go-authgate/authgate/internal/templates" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -122,6 +124,31 @@ func parseTokenPaginationParams(c *gin.Context) store.PaginationParams { return params } +// getFlashMessage retrieves and clears the first flash message from the session. +func getFlashMessage(c *gin.Context) string { + session := sessions.Default(c) + flashes := session.Flashes() + if err := session.Save(); err != nil { + c.Set("session_save_error", err) + } + if len(flashes) > 0 { + if msg, ok := flashes[0].(string); ok { + return msg + } + } + return "" +} + +// flashAndRedirect sets a flash message and redirects to the given URL. +func flashAndRedirect(c *gin.Context, msg, url string) { + session := sessions.Default(c) + session.AddFlash(msg) + if err := session.Save(); err != nil { + c.Set("session_save_error", err) + } + c.Redirect(http.StatusFound, url) +} + // renderErrorPage renders the error page template with the given status code and message. func renderErrorPage(c *gin.Context, statusCode int, message string) { templates.RenderTempl(c, statusCode, templates.ErrorPage( diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5383387f..11004bdf 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -69,6 +69,15 @@ func loadUserFromSession(c *gin.Context, userService *services.UserService) (boo // Transient DB error — don't clear the session return false, err } + // Check if user account is disabled + if !user.IsActive { + session.Clear() + if saveErr := session.Save(); saveErr != nil { + return false, saveErr + } + return false, nil + } + c.Set("user_id", userIDStr) c.Set("user", user) c.Request = c.Request.WithContext(models.SetUserContext(c.Request.Context(), user)) diff --git a/internal/mocks/mock_store.go b/internal/mocks/mock_store.go index 2ef3a28f..84364be7 100644 --- a/internal/mocks/mock_store.go +++ b/internal/mocks/mock_store.go @@ -1170,6 +1170,21 @@ func (mr *MockOAuthConnectionStoreMockRecorder) GetOAuthConnection(provider, pro return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnection", reflect.TypeOf((*MockOAuthConnectionStore)(nil).GetOAuthConnection), provider, providerUserID) } +// GetOAuthConnectionByUserAndID mocks base method. +func (m *MockOAuthConnectionStore) GetOAuthConnectionByUserAndID(userID, connectionID string) (*models.OAuthConnection, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuthConnectionByUserAndID", userID, connectionID) + ret0, _ := ret[0].(*models.OAuthConnection) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuthConnectionByUserAndID indicates an expected call of GetOAuthConnectionByUserAndID. +func (mr *MockOAuthConnectionStoreMockRecorder) GetOAuthConnectionByUserAndID(userID, connectionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnectionByUserAndID", reflect.TypeOf((*MockOAuthConnectionStore)(nil).GetOAuthConnectionByUserAndID), userID, connectionID) +} + // GetOAuthConnectionByUserAndProvider mocks base method. func (m *MockOAuthConnectionStore) GetOAuthConnectionByUserAndProvider(userID, provider string) (*models.OAuthConnection, error) { m.ctrl.T.Helper() @@ -2101,6 +2116,21 @@ func (mr *MockStoreMockRecorder) GetOAuthConnection(provider, providerUserID any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnection", reflect.TypeOf((*MockStore)(nil).GetOAuthConnection), provider, providerUserID) } +// GetOAuthConnectionByUserAndID mocks base method. +func (m *MockStore) GetOAuthConnectionByUserAndID(userID, connectionID string) (*models.OAuthConnection, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuthConnectionByUserAndID", userID, connectionID) + ret0, _ := ret[0].(*models.OAuthConnection) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuthConnectionByUserAndID indicates an expected call of GetOAuthConnectionByUserAndID. +func (mr *MockStoreMockRecorder) GetOAuthConnectionByUserAndID(userID, connectionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnectionByUserAndID", reflect.TypeOf((*MockStore)(nil).GetOAuthConnectionByUserAndID), userID, connectionID) +} + // GetOAuthConnectionByUserAndProvider mocks base method. func (m *MockStore) GetOAuthConnectionByUserAndProvider(userID, provider string) (*models.OAuthConnection, error) { m.ctrl.T.Helper() diff --git a/internal/models/audit_log.go b/internal/models/audit_log.go index 226853e0..551abd84 100644 --- a/internal/models/audit_log.go +++ b/internal/models/audit_log.go @@ -37,11 +37,17 @@ const ( EventClientSecretRegenerated EventType = "CLIENT_SECRET_REGENERATED" // Admin operations — user management + EventUserCreated EventType = "USER_CREATED" EventUserUpdated EventType = "USER_UPDATED" EventUserDeleted EventType = "USER_DELETED" + EventUserDisabled EventType = "USER_DISABLED" + EventUserEnabled EventType = "USER_ENABLED" EventUserRoleChanged EventType = "USER_ROLE_CHANGED" EventUserPasswordReset EventType = "USER_PASSWORD_RESET" //nolint:gosec // G101: false positive, event type constant + // OAuth connection events + EventOAuthConnectionDeleted EventType = "OAUTH_CONNECTION_DELETED" + // Security events EventRateLimitExceeded EventType = "RATE_LIMIT_EXCEEDED" EventSuspiciousActivity EventType = "SUSPICIOUS_ACTIVITY" diff --git a/internal/models/user.go b/internal/models/user.go index 61c51bfa..4ab84948 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -24,6 +24,7 @@ type User struct { Role string `gorm:"not null;default:'user'"` // "admin" or "user" FullName string // User full name AvatarURL string // User avatar URL (from OAuth or manual) + IsActive bool `gorm:"not null;default:true"` // false = disabled by admin // External authentication support ExternalID string `gorm:"index"` // External user ID (e.g., from HTTP API) diff --git a/internal/services/user.go b/internal/services/user.go index 5beeebd9..e9f5fda6 100644 --- a/internal/services/user.go +++ b/internal/services/user.go @@ -45,6 +45,12 @@ var ( ErrInvalidRole = errors.New("role must be admin or user") ErrEmailRequired = errors.New("email is required") ErrEmailConflict = errors.New("email already in use by another user") + ErrAccountDisabled = errors.New("account is disabled") + ErrUsernameRequired = errors.New("username is required") + ErrCannotDisableSelf = errors.New("cannot change your own active status") + ErrUserAlreadyActive = errors.New("user is already active") + ErrUserAlreadyDisabled = errors.New("user is already disabled") + ErrOAuthConnectionNotFound = errors.New("OAuth connection not found") ) type UserService struct { @@ -90,8 +96,11 @@ func (s *UserService) Authenticate( // First, try to find existing user existingUser, err := s.store.GetUserByUsername(username) - // If user exists, authenticate based on their auth_source + // If user exists, check active status then authenticate based on auth_source if err == nil { + if !existingUser.IsActive { + return nil, ErrAccountDisabled + } return s.authenticateExistingUser(ctx, existingUser, password) } @@ -338,6 +347,9 @@ func (s *UserService) AuthenticateWithOAuth( // 2. Check if user exists with same email user, err := s.store.GetUserByEmail(oauthUserInfo.Email) if err == nil { + if !user.IsActive { + return nil, ErrAccountDisabled + } // Only auto-link when the provider has verified the email address. // Without this check, an attacker who controls an OAuth account with // a victim's email could take over the victim's AuthGate account. @@ -391,6 +403,10 @@ func (s *UserService) updateOAuthConnectionAndGetUser( return nil, fmt.Errorf("user not found for OAuth connection: %w", err) } + if !user.IsActive { + return nil, ErrAccountDisabled + } + // Sync avatar and name if changed updated := false if oauthUserInfo.AvatarURL != "" && user.AvatarURL != oauthUserInfo.AvatarURL { @@ -513,6 +529,7 @@ func (s *UserService) createUserWithOAuth( AvatarURL: oauthUserInfo.AvatarURL, Role: models.UserRoleUser, AuthSource: models.AuthSourceLocal, + IsActive: true, PasswordHash: "", // OAuth users have no password } @@ -902,7 +919,245 @@ func (s *UserService) DeleteUserAdmin( return nil } -// CountUsersByRole returns the number of users with the given role. +// CountUsersByRole returns the number of active users with the given role. +// Disabled users are excluded so that last-admin guards work correctly. func (s *UserService) CountUsersByRole(role string) (int64, error) { return s.store.CountUsersByRole(role) } + +// ── Admin Create User ───────────────────────────────────────────────── + +// CreateUserRequest carries the fields for admin user creation. +type CreateUserRequest struct { + Username string + Email string + FullName string + Role string + Password string // optional — if empty, generate random +} + +// CreateUserAdmin creates a new local-auth user. Returns the user and +// the plaintext password (to show once). +func (s *UserService) CreateUserAdmin( + ctx context.Context, + req CreateUserRequest, + actorUserID string, +) (*models.User, string, error) { + // Validate and sanitize required fields + req.Username = sanitizeUsername(strings.TrimSpace(req.Username)) + req.Email = strings.TrimSpace(req.Email) + if req.Username == "" { + return nil, "", ErrUsernameRequired + } + if req.Email == "" { + return nil, "", ErrEmailRequired + } + if req.Role == "" { + req.Role = models.UserRoleUser + } + if req.Role != models.UserRoleAdmin && req.Role != models.UserRoleUser { + return nil, "", ErrInvalidRole + } + + // Check username uniqueness + if _, err := s.store.GetUserByUsername(req.Username); err == nil { + return nil, "", ErrUsernameConflict + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", fmt.Errorf("failed to check username uniqueness: %w", err) + } + + // Check email uniqueness + if _, err := s.store.GetUserByEmail(req.Email); err == nil { + return nil, "", ErrEmailConflict + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", fmt.Errorf("failed to check email uniqueness: %w", err) + } + + // Generate password if not provided + password := req.Password + if password == "" { + var err error + password, err = util.GenerateRandomPassword(16) + if err != nil { + return nil, "", fmt.Errorf("failed to generate password: %w", err) + } + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, "", fmt.Errorf("failed to hash password: %w", err) + } + + user := &models.User{ + ID: uuid.New().String(), + Username: req.Username, + Email: req.Email, + FullName: req.FullName, + Role: req.Role, + PasswordHash: string(hash), + AuthSource: models.AuthSourceLocal, + IsActive: true, + } + + if err := s.store.CreateUser(user); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + // Re-query to determine which unique constraint was violated (race condition). + if _, emailErr := s.store.GetUserByEmail(req.Email); emailErr == nil { + return nil, "", ErrEmailConflict + } + return nil, "", ErrUsernameConflict + } + return nil, "", fmt.Errorf("failed to create user: %w", err) + } + + s.auditService.Log(ctx, core.AuditLogEntry{ + EventType: models.EventUserCreated, + Severity: models.SeverityInfo, + ActorUserID: actorUserID, + ResourceType: models.ResourceUser, + ResourceID: user.ID, + ResourceName: user.Username, + Action: "User created by admin", + Details: models.AuditDetails{ + "username": user.Username, + "email": user.Email, + "role": user.Role, + }, + Success: true, + }) + + return user, password, nil +} + +// ── Admin OAuth Connection Management ───────────────────────────────── + +// GetUserOAuthConnections returns all OAuth connections for a user. +func (s *UserService) GetUserOAuthConnections(userID string) ([]models.OAuthConnection, error) { + return s.store.GetOAuthConnectionsByUserID(userID) +} + +// DeleteUserOAuthConnection deletes a specific OAuth connection for a user. +func (s *UserService) DeleteUserOAuthConnection( + ctx context.Context, + userID, connectionID, actorUserID string, +) error { + // Verify the connection belongs to this user with a single indexed query + target, err := s.store.GetOAuthConnectionByUserAndID(userID, connectionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrOAuthConnectionNotFound + } + return fmt.Errorf("failed to look up OAuth connection: %w", err) + } + + if err := s.store.DeleteOAuthConnection(connectionID); err != nil { + return fmt.Errorf("failed to delete connection: %w", err) + } + + s.auditService.Log(ctx, core.AuditLogEntry{ + EventType: models.EventOAuthConnectionDeleted, + Severity: models.SeverityWarning, + ActorUserID: actorUserID, + ResourceType: models.ResourceUser, + ResourceID: userID, + ResourceName: target.Provider + ":" + target.ProviderUsername, + Action: "OAuth connection removed by admin", + Details: models.AuditDetails{ + "provider": target.Provider, + "provider_username": target.ProviderUsername, + "connection_id": connectionID, + }, + Success: true, + }) + + return nil +} + +// ── Admin Disable/Enable User ───────────────────────────────────────── + +// ValidateSetUserActiveStatus checks whether the active status change is +// allowed without performing it. Callers can use this to run pre-change +// side effects (e.g. token revocation) before committing the update. +func (s *UserService) ValidateSetUserActiveStatus( + userID, actorUserID string, + isActive bool, +) error { + if actorUserID == userID { + return ErrCannotDisableSelf + } + + user, err := s.AdminGetUserByID(userID) + if err != nil { + return err + } + + if isActive && user.IsActive { + return ErrUserAlreadyActive + } + if !isActive && !user.IsActive { + return ErrUserAlreadyDisabled + } + + if !isActive && user.Role == models.UserRoleAdmin { + adminCount, countErr := s.store.CountUsersByRole(models.UserRoleAdmin) + if countErr != nil { + return fmt.Errorf("failed to count admins: %w", countErr) + } + if adminCount <= 1 { + return ErrCannotRemoveLastAdmin + } + } + + return nil +} + +// SetUserActiveStatus enables or disables a user account. +func (s *UserService) SetUserActiveStatus( + ctx context.Context, + userID, actorUserID string, + isActive bool, +) error { + // Re-validate for defense-in-depth (handler calls ValidateSetUserActiveStatus + // separately so it can skip token revocation on validation failure). + if err := s.ValidateSetUserActiveStatus(userID, actorUserID, isActive); err != nil { + return err + } + + user, err := s.AdminGetUserByID(userID) + if err != nil { + return err + } + + user.IsActive = isActive + if err := s.store.UpdateUser(user); err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + s.InvalidateUserCache(userID) + + eventType := models.EventUserEnabled + action := "User account enabled by admin" + severity := models.SeverityInfo + if !isActive { + eventType = models.EventUserDisabled + action = "User account disabled by admin" + severity = models.SeverityWarning + } + + s.auditService.Log(ctx, core.AuditLogEntry{ + EventType: eventType, + Severity: severity, + ActorUserID: actorUserID, + ResourceType: models.ResourceUser, + ResourceID: userID, + ResourceName: user.Username, + Action: action, + Details: models.AuditDetails{ + "username": user.Username, + "is_active": isActive, + }, + Success: true, + }) + + return nil +} diff --git a/internal/services/user_test.go b/internal/services/user_test.go index e635aabb..dabb9b1a 100644 --- a/internal/services/user_test.go +++ b/internal/services/user_test.go @@ -7,7 +7,9 @@ import ( "time" "go.uber.org/mock/gomock" + "golang.org/x/oauth2" + "github.com/go-authgate/authgate/internal/auth" "github.com/go-authgate/authgate/internal/core" "github.com/go-authgate/authgate/internal/mocks" "github.com/go-authgate/authgate/internal/models" @@ -40,6 +42,7 @@ func makeTestUser(t *testing.T, db *store.Store) *models.User { PasswordHash: "hash", Role: "user", AuthSource: AuthModeLocal, + IsActive: true, } require.NoError(t, db.CreateUser(u)) return u @@ -605,3 +608,303 @@ func TestGetUserStats(t *testing.T) { assert.Equal(t, int64(0), stats.OAuthConnectionCount) assert.Equal(t, int64(0), stats.AuthorizationCount) } + +// ── CreateUserAdmin tests ───────────────────────────────────────────── + +func TestCreateUserAdmin_Success(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + svc := newUserServiceWithStore(db, mockCache) + + user, password, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{ + Username: "newuser", + Email: "new@example.com", + FullName: "New User", + }, "actor-id") + require.NoError(t, err) + assert.Equal(t, "newuser", user.Username) + assert.Equal(t, "new@example.com", user.Email) + assert.Equal(t, models.UserRoleUser, user.Role) + assert.True(t, user.IsActive) + assert.NotEmpty(t, password) +} + +func TestCreateUserAdmin_UsernameRequired(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + svc := newUserServiceWithStore(db, mockCache) + + _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{ + Email: "test@example.com", + }, "actor-id") + assert.ErrorIs(t, err, ErrUsernameRequired) +} + +func TestCreateUserAdmin_DuplicateUsername(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + u := makeTestUser(t, db) + svc := newUserServiceWithStore(db, mockCache) + + _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{ + Username: u.Username, + Email: "unique@example.com", + }, "actor-id") + assert.ErrorIs(t, err, ErrUsernameConflict) +} + +func TestCreateUserAdmin_DuplicateEmail(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + u := makeTestUser(t, db) + svc := newUserServiceWithStore(db, mockCache) + + _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{ + Username: "uniqueuser", + Email: u.Email, + }, "actor-id") + assert.ErrorIs(t, err, ErrEmailConflict) +} + +func TestCreateUserAdmin_InvalidRole(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + svc := newUserServiceWithStore(db, mockCache) + + _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{ + Username: "newuser", + Email: "new@example.com", + Role: "superadmin", + }, "actor-id") + assert.ErrorIs(t, err, ErrInvalidRole) +} + +func TestCreateUserAdmin_SanitizesUsername(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + svc := newUserServiceWithStore(db, mockCache) + + user, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{ + Username: "John Doe!@#", + Email: "john@example.com", + }, "actor-id") + require.NoError(t, err) + assert.Equal(t, "johndoe", user.Username) +} + +// ── SetUserActiveStatus tests ───────────────────────────────────────── + +func TestSetUserActiveStatus_DisableUser(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + mockCache.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + u := makeTestUser(t, db) + svc := newUserServiceWithStore(db, mockCache) + + err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", false) + require.NoError(t, err) + + updated, err := svc.AdminGetUserByID(u.ID) + require.NoError(t, err) + assert.False(t, updated.IsActive) +} + +func TestSetUserActiveStatus_EnableUser(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + mockCache.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + u := makeTestUser(t, db) + // First disable + u.IsActive = false + require.NoError(t, db.UpdateUser(u)) + + svc := newUserServiceWithStore(db, mockCache) + err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", true) + require.NoError(t, err) + + updated, err := svc.AdminGetUserByID(u.ID) + require.NoError(t, err) + assert.True(t, updated.IsActive) +} + +func TestSetUserActiveStatus_CannotDisableSelf(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + u := makeTestUser(t, db) + svc := newUserServiceWithStore(db, mockCache) + + err := svc.SetUserActiveStatus(context.Background(), u.ID, u.ID, false) + assert.ErrorIs(t, err, ErrCannotDisableSelf) +} + +func TestSetUserActiveStatus_AlreadyDisabled(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + mockCache.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + u := makeTestUser(t, db) + u.IsActive = false + require.NoError(t, db.UpdateUser(u)) + + svc := newUserServiceWithStore(db, mockCache) + err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", false) + assert.ErrorIs(t, err, ErrUserAlreadyDisabled) +} + +func TestSetUserActiveStatus_AlreadyActive(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + u := makeTestUser(t, db) + svc := newUserServiceWithStore(db, mockCache) + + err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", true) + assert.ErrorIs(t, err, ErrUserAlreadyActive) +} + +// ── DeleteUserOAuthConnection tests ─────────────────────────────────── + +func TestDeleteUserOAuthConnection_Success(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + u := makeTestUser(t, db) + conn := &models.OAuthConnection{ + ID: uuid.New().String(), + UserID: u.ID, + Provider: "github", + ProviderUserID: "gh-123", + ProviderUsername: "ghuser", + ProviderEmail: "gh@example.com", + } + require.NoError(t, db.CreateOAuthConnection(conn)) + + svc := newUserServiceWithStore(db, mockCache) + err := svc.DeleteUserOAuthConnection(context.Background(), u.ID, conn.ID, "actor-id") + require.NoError(t, err) + + // Verify it's deleted + conns, err := db.GetOAuthConnectionsByUserID(u.ID) + require.NoError(t, err) + assert.Empty(t, conns) +} + +func TestDeleteUserOAuthConnection_NotFound(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + u := makeTestUser(t, db) + svc := newUserServiceWithStore(db, mockCache) + + err := svc.DeleteUserOAuthConnection(context.Background(), u.ID, "nonexistent", "actor-id") + require.ErrorIs(t, err, ErrOAuthConnectionNotFound) +} + +func TestDeleteUserOAuthConnection_WrongUser(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + u1 := makeTestUser(t, db) + u2 := makeTestUser(t, db) + conn := &models.OAuthConnection{ + ID: uuid.New().String(), + UserID: u1.ID, + Provider: "github", + ProviderUserID: "gh-456", + ProviderUsername: "ghuser2", + ProviderEmail: "gh2@example.com", + } + require.NoError(t, db.CreateOAuthConnection(conn)) + + svc := newUserServiceWithStore(db, mockCache) + // Attempt to delete u1's connection using u2's ID + err := svc.DeleteUserOAuthConnection(context.Background(), u2.ID, conn.ID, "actor-id") + require.ErrorIs(t, err, ErrOAuthConnectionNotFound) + + // Verify the connection still exists + conns, err := db.GetOAuthConnectionsByUserID(u1.ID) + require.NoError(t, err) + assert.Len(t, conns, 1) +} + +// ── AuthenticateWithOAuth disabled user tests ────────────────────────── + +func TestAuthenticateWithOAuth_DisabledUserWithExistingConnection(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + // Create a disabled user + u := makeTestUser(t, db) + u.IsActive = false + require.NoError(t, db.UpdateUser(u)) + + // Create an OAuth connection for the disabled user + conn := &models.OAuthConnection{ + ID: uuid.New().String(), + UserID: u.ID, + Provider: "github", + ProviderUserID: "gh-disabled-" + uuid.New().String()[:8], + ProviderUsername: "disableduser", + ProviderEmail: u.Email, + } + require.NoError(t, db.CreateOAuthConnection(conn)) + + svc := newUserServiceWithStore(db, mockCache) + + _, err := svc.AuthenticateWithOAuth( + context.Background(), + "github", + &auth.OAuthUserInfo{ + ProviderUserID: conn.ProviderUserID, + Email: u.Email, + Username: "disableduser", + EmailVerified: true, + }, + &oauth2.Token{AccessToken: "tok"}, + ) + require.ErrorIs(t, err, ErrAccountDisabled) +} + +func TestAuthenticateWithOAuth_DisabledUserByEmail(t *testing.T) { + db := setupTestStore(t) + ctrl := gomock.NewController(t) + mockCache := mocks.NewMockCache[models.User](ctrl) + + // Create a disabled user (no OAuth connection) + u := makeTestUser(t, db) + u.IsActive = false + require.NoError(t, db.UpdateUser(u)) + + svc := newUserServiceWithStore(db, mockCache) + + _, err := svc.AuthenticateWithOAuth( + context.Background(), + "github", + &auth.OAuthUserInfo{ + ProviderUserID: "gh-new-" + uuid.New().String()[:8], + Email: u.Email, + Username: "newuser", + EmailVerified: true, + }, + &oauth2.Token{AccessToken: "tok"}, + ) + require.ErrorIs(t, err, ErrAccountDisabled) +} diff --git a/internal/store/dashboard.go b/internal/store/dashboard.go index 03165f4d..db967e2a 100644 --- a/internal/store/dashboard.go +++ b/internal/store/dashboard.go @@ -15,6 +15,7 @@ func (s *Store) GetDashboardCounts() (DashboardCounts, error) { SELECT (SELECT COUNT(*) FROM users) AS total_users, (SELECT COUNT(*) FROM users WHERE role = ?) AS admin_users, + (SELECT COUNT(*) FROM users WHERE is_active = ?) AS disabled_users, (SELECT COUNT(*) FROM oauth_applications) AS total_clients, (SELECT COUNT(*) FROM oauth_applications WHERE status = ?) AS active_clients, (SELECT COUNT(*) FROM oauth_applications WHERE status = ?) AS pending_clients, @@ -22,6 +23,7 @@ func (s *Store) GetDashboardCounts() (DashboardCounts, error) { (SELECT COUNT(*) FROM access_tokens WHERE status = ? AND expires_at > ? AND token_category = ?) AS active_refresh_tokens `, models.UserRoleAdmin, + false, models.ClientStatusActive, models.ClientStatusPending, models.TokenStatusActive, now, models.TokenCategoryAccess, diff --git a/internal/store/oauth_connection.go b/internal/store/oauth_connection.go index d25dd0d1..75a74dd0 100644 --- a/internal/store/oauth_connection.go +++ b/internal/store/oauth_connection.go @@ -44,6 +44,19 @@ func (s *Store) GetOAuthConnectionsByUserID(userID string) ([]models.OAuthConnec return conns, err } +// GetOAuthConnectionByUserAndID finds an OAuth connection by user ID and connection ID. +func (s *Store) GetOAuthConnectionByUserAndID( + userID, connectionID string, +) (*models.OAuthConnection, error) { + var conn models.OAuthConnection + err := s.db.Where("user_id = ? AND id = ?", userID, connectionID). + First(&conn).Error + if err != nil { + return nil, err + } + return &conn, nil +} + // UpdateOAuthConnection updates an existing OAuth connection func (s *Store) UpdateOAuthConnection(conn *models.OAuthConnection) error { return s.db.Save(conn).Error diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 26bba23a..be2a897c 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -179,6 +179,7 @@ func (s *Store) seedData(ctx context.Context, cfg *config.Config) error { Email: "admin@localhost", // Default email for admin PasswordHash: string(hash), Role: models.UserRoleAdmin, + IsActive: true, } if err := s.db.WithContext(ctx).Create(user).Error; err != nil { return err diff --git a/internal/store/types/dashboard.go b/internal/store/types/dashboard.go index 74c57ad6..21cf2a9d 100644 --- a/internal/store/types/dashboard.go +++ b/internal/store/types/dashboard.go @@ -5,6 +5,7 @@ package types type DashboardCounts struct { TotalUsers int64 `gorm:"column:total_users"` AdminUsers int64 `gorm:"column:admin_users"` + DisabledUsers int64 `gorm:"column:disabled_users"` TotalClients int64 `gorm:"column:total_clients"` ActiveClients int64 `gorm:"column:active_clients"` PendingClients int64 `gorm:"column:pending_clients"` diff --git a/internal/store/user.go b/internal/store/user.go index 148070dc..0ff80162 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -105,6 +105,7 @@ func (s *Store) UpsertExternalUser( Username: username, PasswordHash: "", // No local password for external users Role: models.UserRoleUser, + IsActive: true, ExternalID: externalID, AuthSource: authSource, Email: email, @@ -217,10 +218,12 @@ func (s *Store) ListUsersPaginated( return users, pagination, nil } -// CountUsersByRole returns the number of users with the given role. +// CountUsersByRole returns the number of active users with the given role. +// Only active users are counted so that disabled admins do not inflate the +// "last admin" guard used by disable/delete operations. func (s *Store) CountUsersByRole(role string) (int64, error) { var count int64 - query := s.db.Model(&models.User{}) + query := s.db.Model(&models.User{}).Where("is_active = ?", true) if role != "" { query = query.Where("role = ?", role) } diff --git a/internal/store/user_test.go b/internal/store/user_test.go index dd52b887..48043372 100644 --- a/internal/store/user_test.go +++ b/internal/store/user_test.go @@ -20,6 +20,7 @@ func createTestUser(t *testing.T, s *Store, overrides *models.User) *models.User PasswordHash: "hashed", Role: models.UserRoleUser, AuthSource: models.AuthSourceLocal, + IsActive: true, } if overrides != nil { if overrides.Username != "" { @@ -177,6 +178,22 @@ func TestCountUsersByRole(t *testing.T) { require.NoError(t, err) assert.GreaterOrEqual(t, userCount, int64(1)) }) + + t.Run("excludes disabled users", func(t *testing.T) { + store := createFreshStore(t, "sqlite", nil) + // Seeded admin is active. Create a disabled admin. + disabledAdmin := createTestUser(t, store, &models.User{ + Role: models.UserRoleAdmin, + Email: uuid.New().String()[:8] + "@test.com", + }) + disabledAdmin.IsActive = false + require.NoError(t, store.UpdateUser(disabledAdmin)) + + adminCount, err := store.CountUsersByRole(models.UserRoleAdmin) + require.NoError(t, err) + // Only the seeded admin should be counted, not the disabled one + assert.Equal(t, int64(1), adminCount) + }) } func TestGetUserStatsByUserID(t *testing.T) { diff --git a/internal/templates/admin_dashboard.templ b/internal/templates/admin_dashboard.templ index c86c9666..7fbc9ff9 100644 --- a/internal/templates/admin_dashboard.templ +++ b/internal/templates/admin_dashboard.templ @@ -38,6 +38,11 @@ templ AdminDashboard(props DashboardPageProps) {
Total Users
{ pluralize(props.Stats.AdminUsers, "admin", "admins") }, { pluralize(props.Stats.RegularUsers, "user", "users") } + if props.Stats.DisabledUsers > 0 { + + ({ fmt.Sprintf("%d", props.Stats.DisabledUsers) } disabled) + + }
diff --git a/internal/templates/admin_user_authorizations.templ b/internal/templates/admin_user_authorizations.templ new file mode 100644 index 00000000..984cb200 --- /dev/null +++ b/internal/templates/admin_user_authorizations.templ @@ -0,0 +1,103 @@ +package templates + +import ( + "fmt" + "net/url" +) + +templ AdminUserAuthorizations(props UserAuthorizationsPageProps) { + @Layout("User Authorizations", LayoutAdminNavbar, &props.NavbarProps) { +
+
+ +
+
+ } +} diff --git a/internal/templates/admin_user_connections.templ b/internal/templates/admin_user_connections.templ new file mode 100644 index 00000000..b03a90e3 --- /dev/null +++ b/internal/templates/admin_user_connections.templ @@ -0,0 +1,149 @@ +package templates + +import ( + "fmt" + "strings" +) + +func providerDisplayName(provider string) string { + switch strings.ToLower(provider) { + case "github": + return "GitHub" + case "gitea": + return "Gitea" + case "microsoft": + return "Microsoft" + default: + if provider == "" { + return "" + } + return strings.ToUpper(provider[:1]) + provider[1:] + } +} + +templ AdminUserConnections(props UserOAuthConnectionsPageProps) { + @Layout("OAuth Connections", LayoutAdminNavbar, &props.NavbarProps) { +
+
+
+ @Breadcrumb([]BreadcrumbItem{ + {Label: "Admin", Href: "/admin"}, + {Label: "Users", Href: "/admin/users"}, + {Label: props.TargetUser.Username, Href: "/admin/users/" + props.TargetUser.ID}, + {Label: "OAuth Connections", Href: ""}, + }) +
+

OAuth Connections

+

+ External identity providers linked to { props.TargetUser.Username } +

+
+ @Alert(props.Error, AlertError) + @Alert(props.Success, AlertSuccess) + if len(props.Connections) > 0 { + +
+ + { fmt.Sprintf("%d", len(props.Connections)) } linked provider(s) + + + ← Back to User + +
+ +
+ + + + + + + + + + + + + for _, conn := range props.Connections { + + + + + + + + + } + +
ProviderUsernameEmailLast UsedConnectedActions
+ @oauthProviderBadge(conn.Provider) + + { conn.ProviderUsername } + + { conn.ProviderEmail } + + if !conn.LastUsedAt.IsZero() { + + { conn.LastUsedAt.Format("2006-01-02 15:04") } + + } else { + Never + } + + + { conn.CreatedAt.Format("2006-01-02 15:04") } + + +
+ + +
+
+
+ } else { +
+ @EmptyStateAuth() +

No OAuth Connections

+

This user has no linked external identity providers.

+
+
+ + ← Back to User + +
+ } +
+
+
+ } +} + +templ oauthProviderBadge(provider string) { + switch strings.ToLower(provider) { + case "github": + + GitHub + + case "gitea": + + Gitea + + case "microsoft": + + Microsoft + + default: + + { providerDisplayName(provider) } + + } +} diff --git a/internal/templates/admin_user_create.templ b/internal/templates/admin_user_create.templ new file mode 100644 index 00000000..0e1e0b13 --- /dev/null +++ b/internal/templates/admin_user_create.templ @@ -0,0 +1,98 @@ +package templates + +import "github.com/go-authgate/authgate/internal/models" + +templ AdminUserCreate(props UserCreatePageProps) { + @Layout("Create User", LayoutAdminNavbar, &props.NavbarProps) { +
+
+
+ @Breadcrumb([]BreadcrumbItem{ + {Label: "Admin", Href: "/admin"}, + {Label: "Users", Href: "/admin/users"}, + {Label: "Create User", Href: ""}, + }) +
+

Create User

+

+ Create a new local user account. A password will be auto-generated if not provided. +

+
+ @Alert(props.Error, AlertError) +
+ + +
+ + + Must be unique. Letters, numbers, underscores, and hyphens only. +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + If left blank, a secure random password will be generated and shown once. +
+ +
+ + Cancel +
+
+
+
+
+ } +} diff --git a/internal/templates/admin_user_created.templ b/internal/templates/admin_user_created.templ new file mode 100644 index 00000000..e1b46c02 --- /dev/null +++ b/internal/templates/admin_user_created.templ @@ -0,0 +1,82 @@ +package templates + +templ AdminUserCreated(props UserCreatedPageProps) { + @Layout("User Created", LayoutAdminNavbar, &props.NavbarProps) { +
+
+
+ @Breadcrumb([]BreadcrumbItem{ + {Label: "Admin", Href: "/admin"}, + {Label: "Users", Href: "/admin/users"}, + {Label: props.TargetUser.Username, Href: "/admin/users/" + props.TargetUser.ID}, + {Label: "Created", Href: ""}, + }) +
+

+ User Created +

+
+
+ User "{ props.TargetUser.Username }" has been created successfully. +
+
+ +
+ Important Security Notice +

+ This password is shown only once for security reasons. + Please copy and share it with the user through a secure channel immediately. +

+
+
+
+
+ Password +

+ Copy this password and share it securely. You won't be able to see it again. +

+
+
{ props.NewPassword }
+ +
+ +
+
+
Username
+
{ props.TargetUser.Username }
+
+
+
Email
+
{ props.TargetUser.Email }
+
+ if props.TargetUser.FullName != "" { +
+
Full Name
+
{ props.TargetUser.FullName }
+
+ } +
+
Role
+
+ @UserRoleBadge(props.TargetUser.Role) +
+
+
+ +
+
+
+ } +} diff --git a/internal/templates/admin_user_detail.templ b/internal/templates/admin_user_detail.templ index 78937af8..1d0c0332 100644 --- a/internal/templates/admin_user_detail.templ +++ b/internal/templates/admin_user_detail.templ @@ -2,6 +2,7 @@ package templates import ( "fmt" + "net/url" "github.com/go-authgate/authgate/internal/models" ) @@ -50,6 +51,12 @@ templ AdminUserDetail(props UserDetailPageProps) { @UserRoleBadge(props.TargetUser.Role) +
+
Status
+
+ @UserStatusBadge(props.TargetUser.IsActive) +
+
Auth Source
@@ -65,20 +72,20 @@ templ AdminUserDetail(props UserDetailPageProps) {
{ props.TargetUser.UpdatedAt.Format("2006-01-02 15:04:05") }
- +
-
+
{ fmt.Sprintf("%d", props.ActiveTokenCount) }
Active Tokens
-
-
+ +
{ fmt.Sprintf("%d", props.OAuthConnectionCount) }
OAuth Connections
-
-
+ +
{ fmt.Sprintf("%d", props.AuthorizationCount) }
Authorized Apps
-
+
@@ -100,6 +107,34 @@ templ AdminUserDetail(props UserDetailPageProps) { } + + if props.TargetUser.IsActive { +
+ + +
+ } else { +
+ + +
+ } ← Back to List @@ -148,3 +183,11 @@ templ AdminUserDetail(props UserDetailPageProps) {
} } + +templ UserStatusBadge(isActive bool) { + if isActive { + Active + } else { + Disabled + } +} diff --git a/internal/templates/admin_users.templ b/internal/templates/admin_users.templ index 1ffb3da4..c938ae74 100644 --- a/internal/templates/admin_users.templ +++ b/internal/templates/admin_users.templ @@ -40,6 +40,10 @@ templ AdminUsers(props UsersPageProps) { Users + + + Create User + @Alert(props.Success, AlertSuccess) @SfToolbar() { @@ -104,6 +108,11 @@ templ AdminUsers(props UsersPageProps) { Auth Source + +
+ Status +
+
Created @@ -177,6 +186,9 @@ templ UserTableRow(u models.User, csrfToken string) { @UserAuthSourceBadge(u.AuthSource) + + @UserStatusBadge(u.IsActive) +
{ u.CreatedAt.Format("2006-01-02") } diff --git a/internal/templates/props.go b/internal/templates/props.go index c91f916b..127f4614 100644 --- a/internal/templates/props.go +++ b/internal/templates/props.go @@ -404,6 +404,45 @@ type TokensPageProps struct { Now time.Time } +// UserOAuthConnectionsPageProps contains properties for the admin user OAuth connections page +type UserOAuthConnectionsPageProps struct { + BaseProps + NavbarProps + TargetUser *models.User + Connections []models.OAuthConnection + Success string + Error string +} + +// UserAuthorizationsPageProps contains properties for the admin user authorizations page +type UserAuthorizationsPageProps struct { + BaseProps + NavbarProps + TargetUser *models.User + Authorizations []AuthorizationDisplay + Success string + Error string +} + +// UserCreatePageProps contains properties for the admin user create form +type UserCreatePageProps struct { + BaseProps + NavbarProps + Error string + Username string // form repopulation on error + Email string + FullName string + Role string +} + +// UserCreatedPageProps contains properties for the admin user created success page +type UserCreatedPageProps struct { + BaseProps + NavbarProps + TargetUser *models.User + NewPassword string +} + // AuditLogsPageProps contains properties for the audit logs page type AuditLogsPageProps struct { BaseProps diff --git a/internal/templates/static/css/pages/admin-forms.css b/internal/templates/static/css/pages/admin-forms.css index 4d911004..122f8313 100644 --- a/internal/templates/static/css/pages/admin-forms.css +++ b/internal/templates/static/css/pages/admin-forms.css @@ -659,6 +659,27 @@ transform: translateY(-2px); } +a.admin-user-stat-card-link { + text-decoration: none; + color: inherit; + cursor: pointer; + display: block; +} + +a.admin-user-stat-card-link:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-lg); +} + +a.admin-user-stat-card-link:hover .admin-user-stat-value { + color: var(--color-primary); +} + +a.admin-user-stat-card-link:hover .admin-user-stat-label { + color: var(--color-primary); +} + .admin-user-stat-value { font-size: var(--text-3xl); font-weight: 800;