mirror of
https://github.com/eduardolat/pgbackweb.git
synced 2026-05-12 22:48:27 -05:00
📝 Add docstrings to feat/add-oidc-auth
Docstrings generation was requested by @eduardolat. * https://github.com/eduardolat/pgbackweb/pull/126#issuecomment-3089272934 The following files were modified: * `cmd/app/main.go` * `cmd/changepw/main.go` * `internal/config/env_validate.go` * `internal/service/oidc/oidc.go` * `internal/service/service.go` * `internal/service/users/users.go` * `internal/view/web/auth/login.go` * `internal/view/web/auth/router.go` * `internal/view/web/dashboard/profile/update_user.go` * `internal/view/web/oidc/router.go`
This commit is contained in:
committed by
GitHub
parent
8054b127e7
commit
958f098def
+5
-1
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// main initializes environment configuration, scheduled tasks, database connections, services, and starts the web server.
|
||||
func main() {
|
||||
env, err := config.GetEnv()
|
||||
if err != nil {
|
||||
@@ -38,7 +39,10 @@ func main() {
|
||||
dbgen := dbgen.New(db)
|
||||
|
||||
ints := integration.New()
|
||||
servs := service.New(env, dbgen, cr, ints)
|
||||
servs, err := service.New(env, dbgen, cr, ints)
|
||||
if err != nil {
|
||||
logger.FatalError("error initializing services", logger.KV{"error": err})
|
||||
}
|
||||
initSchedule(cr, servs)
|
||||
|
||||
app := echo.New()
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// main runs a command-line utility to reset a user's password in the PostgreSQL database.
|
||||
// It prompts for a user's email, verifies the user exists, generates a new random password,
|
||||
// updates the password in the database, and displays the new password to the operator.
|
||||
func main() {
|
||||
env, err := config.GetEnv()
|
||||
if err != nil {
|
||||
@@ -60,7 +63,7 @@ func main() {
|
||||
err = dbg.UsersServiceChangePassword(
|
||||
context.Background(), dbgen.UsersServiceChangePasswordParams{
|
||||
ID: userID,
|
||||
Password: hashedPassword,
|
||||
Password: sql.NullString{String: hashedPassword, Valid: true},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"github.com/eduardolat/pgbackweb/internal/validate"
|
||||
)
|
||||
|
||||
// validateEnv runs additional validations on the environment variables.
|
||||
// validateEnv checks the validity of environment variables in the Env struct, including listen host, port, and required OIDC parameters if OIDC is enabled.
|
||||
// It returns an error describing the first validation failure encountered, or nil if all checks pass.
|
||||
func validateEnv(env Env) error {
|
||||
if !validate.ListenHost(env.PBW_LISTEN_HOST) {
|
||||
return fmt.Errorf("invalid listen address %s", env.PBW_LISTEN_HOST)
|
||||
@@ -16,5 +17,21 @@ func validateEnv(env Env) error {
|
||||
return fmt.Errorf("invalid listen port %s, valid values are 1-65535", env.PBW_LISTEN_PORT)
|
||||
}
|
||||
|
||||
// Validate OIDC configuration if enabled
|
||||
if env.PBW_OIDC_ENABLED {
|
||||
if env.PBW_OIDC_ISSUER_URL == "" {
|
||||
return fmt.Errorf("PBW_OIDC_ISSUER_URL is required when OIDC is enabled")
|
||||
}
|
||||
if env.PBW_OIDC_CLIENT_ID == "" {
|
||||
return fmt.Errorf("PBW_OIDC_CLIENT_ID is required when OIDC is enabled")
|
||||
}
|
||||
if env.PBW_OIDC_CLIENT_SECRET == "" {
|
||||
return fmt.Errorf("PBW_OIDC_CLIENT_SECRET is required when OIDC is enabled")
|
||||
}
|
||||
if env.PBW_OIDC_REDIRECT_URL == "" {
|
||||
return fmt.Errorf("PBW_OIDC_REDIRECT_URL is required when OIDC is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/eduardolat/pgbackweb/internal/config"
|
||||
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Custom error types for better error handling
|
||||
var (
|
||||
ErrEmailAlreadyExists = errors.New("email already exists with different authentication method")
|
||||
ErrOIDCNotEnabled = errors.New("OIDC is not enabled")
|
||||
ErrInvalidToken = errors.New("invalid or expired token")
|
||||
ErrMissingClaims = errors.New("required user information missing from OIDC claims")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
env config.Env
|
||||
dbgen *dbgen.Queries
|
||||
provider *oidc.Provider
|
||||
config oauth2.Config
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Email string
|
||||
Name string
|
||||
Username string
|
||||
Subject string
|
||||
}
|
||||
|
||||
// New initializes and returns a new OIDC Service using the provided environment configuration and database queries.
|
||||
// If OIDC is disabled in the environment, returns a Service with minimal setup.
|
||||
// Returns an error if the OIDC provider cannot be created.
|
||||
func New(env config.Env, dbgen *dbgen.Queries) (*Service, error) {
|
||||
if !env.PBW_OIDC_ENABLED {
|
||||
return &Service{env: env, dbgen: dbgen}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
provider, err := oidc.NewProvider(ctx, env.PBW_OIDC_ISSUER_URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
scopes := strings.Split(env.PBW_OIDC_SCOPES, " ")
|
||||
config := oauth2.Config{
|
||||
ClientID: env.PBW_OIDC_CLIENT_ID,
|
||||
ClientSecret: env.PBW_OIDC_CLIENT_SECRET,
|
||||
RedirectURL: env.PBW_OIDC_REDIRECT_URL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
return &Service{
|
||||
env: env,
|
||||
dbgen: dbgen,
|
||||
provider: provider,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) IsEnabled() bool {
|
||||
return s.env.PBW_OIDC_ENABLED
|
||||
}
|
||||
|
||||
func (s *Service) GetAuthURL(state string) string {
|
||||
if !s.IsEnabled() {
|
||||
return ""
|
||||
}
|
||||
return s.config.AuthCodeURL(state)
|
||||
}
|
||||
|
||||
func (s *Service) GenerateState() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (s *Service) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) {
|
||||
if !s.IsEnabled() {
|
||||
return nil, ErrOIDCNotEnabled
|
||||
}
|
||||
|
||||
token, err := s.config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no id_token field in oauth2 token")
|
||||
}
|
||||
|
||||
verifier := s.provider.Verifier(&oidc.Config{ClientID: s.env.PBW_OIDC_CLIENT_ID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify ID token: %w", err)
|
||||
}
|
||||
|
||||
claims := make(map[string]interface{})
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
userInfo := &UserInfo{
|
||||
Subject: idToken.Subject,
|
||||
}
|
||||
|
||||
// Extract email
|
||||
if email, ok := claims[s.env.PBW_OIDC_EMAIL_CLAIM].(string); ok {
|
||||
userInfo.Email = strings.ToLower(email)
|
||||
}
|
||||
|
||||
// Extract name
|
||||
if name, ok := claims[s.env.PBW_OIDC_NAME_CLAIM].(string); ok {
|
||||
userInfo.Name = name
|
||||
}
|
||||
|
||||
// Extract username
|
||||
if username, ok := claims[s.env.PBW_OIDC_USERNAME_CLAIM].(string); ok {
|
||||
userInfo.Username = username
|
||||
}
|
||||
|
||||
// Fallback to email as username if username not provided
|
||||
if userInfo.Username == "" && userInfo.Email != "" {
|
||||
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
|
||||
}
|
||||
|
||||
// Fallback to username as name if name not provided
|
||||
if userInfo.Name == "" && userInfo.Username != "" {
|
||||
userInfo.Name = userInfo.Username
|
||||
}
|
||||
|
||||
if userInfo.Email == "" || userInfo.Name == "" || userInfo.Subject == "" {
|
||||
return nil, ErrMissingClaims
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateOrUpdateUser(ctx context.Context, userInfo *UserInfo) (*dbgen.User, error) {
|
||||
// Try to get existing OIDC user
|
||||
_, err := s.dbgen.OIDCServiceGetUserByOIDC(ctx, dbgen.OIDCServiceGetUserByOIDCParams{
|
||||
OidcProvider: sql.NullString{String: "oidc", Valid: true},
|
||||
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
// OIDC user exists, update their information
|
||||
user, err := s.dbgen.OIDCServiceUpdateUser(ctx, dbgen.OIDCServiceUpdateUserParams{
|
||||
Name: userInfo.Name,
|
||||
Email: userInfo.Email,
|
||||
OidcProvider: sql.NullString{String: "oidc", Valid: true},
|
||||
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// OIDC user doesn't exist, check if regular user with same email exists
|
||||
_, err = s.dbgen.AuthServiceLoginGetUserByEmail(ctx, strings.ToLower(userInfo.Email))
|
||||
if err == nil {
|
||||
// Regular user with same email exists - we cannot create OIDC user
|
||||
// This prevents account takeover and maintains data integrity
|
||||
return nil, ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
// No existing user, create new OIDC user
|
||||
user, err := s.dbgen.OIDCServiceCreateUser(ctx, dbgen.OIDCServiceCreateUserParams{
|
||||
Name: userInfo.Name,
|
||||
Email: userInfo.Email,
|
||||
OidcProvider: sql.NullString{String: "oidc", Valid: true},
|
||||
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/eduardolat/pgbackweb/internal/service/databases"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/destinations"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/executions"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/oidc"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/restorations"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/users"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/webhooks"
|
||||
@@ -21,17 +22,24 @@ type Service struct {
|
||||
DatabasesService *databases.Service
|
||||
DestinationsService *destinations.Service
|
||||
ExecutionsService *executions.Service
|
||||
OIDCService *oidc.Service
|
||||
UsersService *users.Service
|
||||
RestorationsService *restorations.Service
|
||||
WebhooksService *webhooks.Service
|
||||
}
|
||||
|
||||
// New constructs and initializes a Service instance with all component services.
|
||||
// Returns the assembled Service or an error if OIDC service initialization fails.
|
||||
func New(
|
||||
env config.Env, dbgen *dbgen.Queries,
|
||||
cr *cron.Cron, ints *integration.Integration,
|
||||
) *Service {
|
||||
) (*Service, error) {
|
||||
webhooksService := webhooks.New(dbgen)
|
||||
authService := auth.New(env, dbgen)
|
||||
oidcService, err := oidc.New(env, dbgen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databasesService := databases.New(env, dbgen, ints, webhooksService)
|
||||
destinationsService := destinations.New(env, dbgen, ints, webhooksService)
|
||||
executionsService := executions.New(env, dbgen, ints, webhooksService)
|
||||
@@ -47,8 +55,9 @@ func New(
|
||||
DatabasesService: databasesService,
|
||||
DestinationsService: destinationsService,
|
||||
ExecutionsService: executionsService,
|
||||
OIDCService: oidcService,
|
||||
UsersService: usersService,
|
||||
RestorationsService: restorationsService,
|
||||
WebhooksService: webhooksService,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ type Service struct {
|
||||
dbgen *dbgen.Queries
|
||||
}
|
||||
|
||||
// New creates and returns a new Service instance using the provided database queries handler.
|
||||
func New(dbgen *dbgen.Queries) *Service {
|
||||
return &Service{
|
||||
dbgen: dbgen,
|
||||
}
|
||||
}
|
||||
|
||||
// IsOIDCUser checks if a user is authenticated via OIDC
|
||||
func (s *Service) IsOIDCUser(user dbgen.User) bool {
|
||||
return user.OidcProvider.Valid && user.OidcSubject.Valid
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/eduardolat/pgbackweb/internal/logger"
|
||||
"github.com/eduardolat/pgbackweb/internal/util/echoutil"
|
||||
@@ -31,13 +32,67 @@ func (h *handlers) loginPageHandler(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/auth/create-first-user")
|
||||
}
|
||||
|
||||
return echoutil.RenderNodx(c, http.StatusOK, loginPage())
|
||||
// Check for error message in URL parameters
|
||||
errorMsg := c.QueryParam("error")
|
||||
if errorMsg != "" {
|
||||
// URL decode the error message to handle encoded characters
|
||||
if decodedMsg, err := url.QueryUnescape(errorMsg); err == nil {
|
||||
errorMsg = decodedMsg
|
||||
}
|
||||
}
|
||||
|
||||
return echoutil.RenderNodx(c, http.StatusOK, loginPage(h.servs.OIDCService.IsEnabled(), errorMsg))
|
||||
}
|
||||
|
||||
func loginPage() nodx.Node {
|
||||
// loginPage constructs the login page UI as a NodX node tree, optionally displaying an error message and an OIDC login option.
|
||||
//
|
||||
// If an error message is provided, a toast notification is triggered on page load. If OIDC login is enabled, a button for SSO login and a divider are included before the traditional email/password login form.
|
||||
//
|
||||
// Returns the complete login page node wrapped in the authentication layout.
|
||||
func loginPage(oidcEnabled bool, errorMsg string) nodx.Node {
|
||||
content := []nodx.Node{
|
||||
component.H1Text("Login"),
|
||||
}
|
||||
|
||||
// Add JavaScript to show toast notification if error message is present
|
||||
if errorMsg != "" {
|
||||
// Use a data attribute to safely pass the error message to JavaScript
|
||||
content = append(content,
|
||||
nodx.Script(
|
||||
nodx.Attr("data-error-message", errorMsg),
|
||||
nodx.Text(`
|
||||
(function() {
|
||||
const errorMsg = document.currentScript.dataset.errorMessage;
|
||||
if (errorMsg) {
|
||||
window.toaster.error(errorMsg);
|
||||
}
|
||||
})();
|
||||
`),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Add OIDC login option if enabled
|
||||
if oidcEnabled {
|
||||
content = append(content,
|
||||
nodx.Div(
|
||||
nodx.Class("mt-4"),
|
||||
nodx.A(
|
||||
nodx.Href("/auth/oidc/login"),
|
||||
nodx.Class("btn btn-outline btn-block"),
|
||||
component.SpanText("Login with SSO"),
|
||||
lucide.ExternalLink(),
|
||||
),
|
||||
),
|
||||
nodx.Div(
|
||||
nodx.Class("divider"),
|
||||
nodx.Text("OR"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Traditional login form
|
||||
content = append(content,
|
||||
nodx.FormEl(
|
||||
htmx.HxPost("/auth/login"),
|
||||
htmx.HxDisabledELT("find button"),
|
||||
@@ -72,7 +127,7 @@ func loginPage() nodx.Node {
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return layout.Auth(layout.AuthParams{
|
||||
Title: "Login",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/eduardolat/pgbackweb/internal/service"
|
||||
"github.com/eduardolat/pgbackweb/internal/view/middleware"
|
||||
"github.com/eduardolat/pgbackweb/internal/view/web/oidc"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -12,6 +13,7 @@ type handlers struct {
|
||||
servs *service.Service
|
||||
}
|
||||
|
||||
// MountRouter registers authentication-related HTTP routes on the provided Echo group, applying appropriate middleware for authenticated and unauthenticated access. It also mounts OpenID Connect (OIDC) routes on the same group.
|
||||
func MountRouter(
|
||||
parent *echo.Group, mids *middleware.Middleware, servs *service.Service,
|
||||
) {
|
||||
@@ -31,4 +33,7 @@ func MountRouter(
|
||||
|
||||
requireAuth.POST("/logout", h.logoutHandler)
|
||||
requireAuth.POST("/logout-all", h.logoutAllSessionsHandler)
|
||||
|
||||
// Mount OIDC routes
|
||||
oidc.MountRouter(parent, mids, servs)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ func (h *handlers) updateUserHandler(c echo.Context) error {
|
||||
reqCtx := reqctx.GetCtx(c)
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Check if user is OIDC user
|
||||
isOIDCUser := reqCtx.User.OidcProvider.Valid && reqCtx.User.OidcSubject.Valid
|
||||
|
||||
// Block profile updates for OIDC users
|
||||
if isOIDCUser {
|
||||
return respondhtmx.ToastError(c, "Profile updates are not allowed for SSO users. Your profile is managed by your identity provider.")
|
||||
}
|
||||
|
||||
var formData struct {
|
||||
Name string `form:"name" validate:"required"`
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
@@ -27,15 +35,18 @@ func (h *handlers) updateUserHandler(c echo.Context) error {
|
||||
if err := c.Bind(&formData); err != nil {
|
||||
return respondhtmx.ToastError(c, err.Error())
|
||||
}
|
||||
|
||||
if err := validate.Struct(&formData); err != nil {
|
||||
return respondhtmx.ToastError(c, err.Error())
|
||||
}
|
||||
|
||||
passwordUpdate := sql.NullString{String: formData.Password, Valid: formData.Password != ""}
|
||||
|
||||
_, err := h.servs.UsersService.UpdateUser(ctx, dbgen.UsersServiceUpdateUserParams{
|
||||
ID: reqCtx.User.ID,
|
||||
Name: sql.NullString{String: formData.Name, Valid: true},
|
||||
Email: sql.NullString{String: formData.Email, Valid: true},
|
||||
Password: sql.NullString{String: formData.Password, Valid: formData.Password != ""},
|
||||
Password: passwordUpdate,
|
||||
})
|
||||
if err != nil {
|
||||
return respondhtmx.ToastError(c, err.Error())
|
||||
@@ -44,67 +55,108 @@ func (h *handlers) updateUserHandler(c echo.Context) error {
|
||||
return respondhtmx.ToastSuccess(c, "Profile updated")
|
||||
}
|
||||
|
||||
// updateUserForm returns a profile update form UI node tailored to the user's authentication type.
|
||||
// For OIDC (SSO) users, the form disables editing and displays an informational alert; for regular users, it provides editable fields for name, email, and password updates.
|
||||
func updateUserForm(user dbgen.User) nodx.Node {
|
||||
// Check if user is OIDC user
|
||||
isOIDCUser := user.OidcProvider.Valid && user.OidcSubject.Valid
|
||||
|
||||
// Build form fields
|
||||
formFields := []nodx.Node{
|
||||
component.H2Text("Update profile"),
|
||||
|
||||
// Show different message for OIDC users
|
||||
nodx.If(isOIDCUser,
|
||||
nodx.Div(
|
||||
nodx.Class("alert alert-info mb-4"),
|
||||
nodx.Div(
|
||||
nodx.Class("flex items-center space-x-2"),
|
||||
lucide.Info(),
|
||||
nodx.Div(
|
||||
nodx.Class("text-sm"),
|
||||
nodx.Text("You are logged in via SSO. Your profile information is managed by your identity provider and cannot be changed here."),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "name",
|
||||
Label: "Full name",
|
||||
Placeholder: "Your full name",
|
||||
Required: !isOIDCUser, // Don't require if disabled
|
||||
Type: component.InputTypeText,
|
||||
AutoComplete: "name",
|
||||
Children: []nodx.Node{
|
||||
nodx.Value(user.Name),
|
||||
nodx.If(isOIDCUser, nodx.Disabled("")),
|
||||
nodx.If(isOIDCUser, nodx.Readonly("")),
|
||||
},
|
||||
}),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "email",
|
||||
Label: "Email",
|
||||
Placeholder: "Your email",
|
||||
Required: !isOIDCUser, // Don't require if disabled
|
||||
AutoComplete: "email",
|
||||
Type: component.InputTypeEmail,
|
||||
Children: []nodx.Node{
|
||||
nodx.Value(user.Email),
|
||||
nodx.If(isOIDCUser, nodx.Disabled("")),
|
||||
nodx.If(isOIDCUser, nodx.Readonly("")),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Add password fields only for non-OIDC users
|
||||
if !isOIDCUser {
|
||||
formFields = append(formFields,
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "password",
|
||||
Label: "Change password",
|
||||
Placeholder: "New password",
|
||||
AutoComplete: "new-password",
|
||||
Type: component.InputTypePassword,
|
||||
HelpText: "Leave empty to keep your current password",
|
||||
}),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "password_confirmation",
|
||||
Label: "Confirm password",
|
||||
Placeholder: "Confirm new password",
|
||||
AutoComplete: "new-password",
|
||||
Type: component.InputTypePassword,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Add submit button (disabled for OIDC users)
|
||||
formFields = append(formFields,
|
||||
nodx.Div(
|
||||
nodx.Class("flex justify-end items-center space-x-2 pt-2"),
|
||||
component.HxLoadingMd(),
|
||||
nodx.Button(
|
||||
nodx.ClassMap{
|
||||
"btn btn-primary": !isOIDCUser,
|
||||
"btn btn-disabled": isOIDCUser,
|
||||
},
|
||||
nodx.Type("submit"),
|
||||
nodx.If(isOIDCUser, nodx.Disabled("")),
|
||||
component.SpanText("Save changes"),
|
||||
lucide.Save(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return component.CardBox(component.CardBoxParams{
|
||||
Children: []nodx.Node{
|
||||
nodx.FormEl(
|
||||
htmx.HxPost("/dashboard/profile"),
|
||||
htmx.HxDisabledELT("find button"),
|
||||
nodx.Class("space-y-2"),
|
||||
|
||||
component.H2Text("Update profile"),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "name",
|
||||
Label: "Full name",
|
||||
Placeholder: "Your full name",
|
||||
Required: true,
|
||||
Type: component.InputTypeText,
|
||||
AutoComplete: "name",
|
||||
Children: []nodx.Node{
|
||||
nodx.Value(user.Name),
|
||||
},
|
||||
}),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "email",
|
||||
Label: "Email",
|
||||
Placeholder: "Your email",
|
||||
Required: true,
|
||||
AutoComplete: "email",
|
||||
Type: component.InputTypeEmail,
|
||||
Children: []nodx.Node{
|
||||
nodx.Value(user.Email),
|
||||
},
|
||||
}),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "password",
|
||||
Label: "Change password",
|
||||
Placeholder: "New password",
|
||||
AutoComplete: "new-password",
|
||||
Type: component.InputTypePassword,
|
||||
HelpText: "Leave empty to keep your current password",
|
||||
}),
|
||||
|
||||
component.InputControl(component.InputControlParams{
|
||||
Name: "password_confirmation",
|
||||
Label: "Confirm password",
|
||||
Placeholder: "Confirm new password",
|
||||
AutoComplete: "new-password",
|
||||
Type: component.InputTypePassword,
|
||||
}),
|
||||
|
||||
nodx.Div(
|
||||
nodx.Class("flex justify-end items-center space-x-2 pt-2"),
|
||||
component.HxLoadingMd(),
|
||||
nodx.Button(
|
||||
nodx.Class("btn btn-primary"),
|
||||
nodx.Type("submit"),
|
||||
component.SpanText("Save changes"),
|
||||
lucide.Save(),
|
||||
),
|
||||
),
|
||||
append([]nodx.Node{
|
||||
nodx.If(!isOIDCUser, htmx.HxPost("/dashboard/profile")),
|
||||
nodx.If(!isOIDCUser, htmx.HxDisabledELT("find button")),
|
||||
nodx.Class("space-y-2"),
|
||||
}, formFields...)...,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/eduardolat/pgbackweb/internal/logger"
|
||||
"github.com/eduardolat/pgbackweb/internal/service"
|
||||
"github.com/eduardolat/pgbackweb/internal/service/oidc"
|
||||
"github.com/eduardolat/pgbackweb/internal/view/middleware"
|
||||
"github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
servs *service.Service
|
||||
}
|
||||
|
||||
// MountRouter registers OIDC authentication routes on the provided Echo group if OIDC is enabled.
|
||||
// It sets up login and callback endpoints under a middleware group that requires no prior authentication.
|
||||
func MountRouter(
|
||||
parent *echo.Group, mids *middleware.Middleware, servs *service.Service,
|
||||
) {
|
||||
if !servs.OIDCService.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
h := handlers{servs: servs}
|
||||
|
||||
requireNoAuth := parent.Group("", mids.RequireNoAuth)
|
||||
|
||||
requireNoAuth.GET("/oidc/login", h.oidcLoginHandler)
|
||||
requireNoAuth.GET("/oidc/callback", h.oidcCallbackHandler)
|
||||
}
|
||||
|
||||
func (h *handlers) oidcLoginHandler(c echo.Context) error {
|
||||
state, err := h.servs.OIDCService.GenerateState()
|
||||
if err != nil {
|
||||
logger.Error("OIDC: failed to generate state", logger.KV{
|
||||
"ip": c.RealIP(),
|
||||
"ua": c.Request().UserAgent(),
|
||||
"error": err,
|
||||
})
|
||||
return handleOIDCError(c, "OIDC: Unable to initiate login")
|
||||
}
|
||||
|
||||
// Store state in session/cookie for verification
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: state,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 300, // 5 minutes
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
authURL := h.servs.OIDCService.GetAuthURL(state)
|
||||
return c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
func (h *handlers) oidcCallbackHandler(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Verify state parameter
|
||||
state := c.QueryParam("state")
|
||||
stateCookie, err := c.Cookie("oidc_state")
|
||||
if err != nil || stateCookie == nil || stateCookie.Value != state {
|
||||
expectedValue := ""
|
||||
if stateCookie != nil {
|
||||
expectedValue = stateCookie.Value
|
||||
}
|
||||
logger.Error("OIDC: state mismatch", logger.KV{
|
||||
"ip": c.RealIP(),
|
||||
"ua": c.Request().UserAgent(),
|
||||
"state": state,
|
||||
"expected": expectedValue,
|
||||
})
|
||||
return handleOIDCError(c, "OIDC: Invalid state parameter")
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: "",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
// Check for error from OIDC provider
|
||||
if errorParam := c.QueryParam("error"); errorParam != "" {
|
||||
errorDesc := c.QueryParam("error_description")
|
||||
errorMsg := "OIDC: Login failed"
|
||||
if errorDesc != "" {
|
||||
errorMsg = "OIDC: " + errorDesc
|
||||
} else {
|
||||
errorMsg = "OIDC: " + errorParam
|
||||
}
|
||||
logger.Error("OIDC provider returned error", logger.KV{
|
||||
"ip": c.RealIP(),
|
||||
"ua": c.Request().UserAgent(),
|
||||
"error": errorParam,
|
||||
"error_description": errorDesc,
|
||||
})
|
||||
return handleOIDCError(c, errorMsg)
|
||||
}
|
||||
|
||||
code := c.QueryParam("code")
|
||||
if code == "" {
|
||||
return handleOIDCError(c, "OIDC: Missing authorization code")
|
||||
}
|
||||
|
||||
// Exchange code for user info
|
||||
userInfo, err := h.servs.OIDCService.ExchangeCode(ctx, code)
|
||||
if err != nil {
|
||||
logger.Error("failed to exchange OIDC code", logger.KV{
|
||||
"ip": c.RealIP(),
|
||||
"ua": c.Request().UserAgent(),
|
||||
"error": err,
|
||||
})
|
||||
return handleOIDCError(c, "OIDC: Unable to authenticate with provider")
|
||||
}
|
||||
|
||||
// Create or update user
|
||||
user, err := h.servs.OIDCService.CreateOrUpdateUser(ctx, userInfo)
|
||||
if err != nil {
|
||||
errorMsg := "OIDC: Unable to create user account"
|
||||
if errors.Is(err, oidc.ErrEmailAlreadyExists) {
|
||||
errorMsg = "OIDC: Email already exists. Use regular login."
|
||||
}
|
||||
logger.Error("failed to create/update OIDC user", logger.KV{
|
||||
"ip": c.RealIP(),
|
||||
"ua": c.Request().UserAgent(),
|
||||
"email": userInfo.Email,
|
||||
"error": err,
|
||||
})
|
||||
return handleOIDCError(c, errorMsg)
|
||||
}
|
||||
|
||||
logger.Info("OIDC: authentication successful", logger.KV{
|
||||
"email": userInfo.Email,
|
||||
"name": userInfo.Name,
|
||||
"subject": userInfo.Subject,
|
||||
"user_id": user.ID,
|
||||
})
|
||||
|
||||
// Create session for the user
|
||||
session, err := h.servs.AuthService.LoginOIDC(
|
||||
ctx, user.ID, c.RealIP(), c.Request().UserAgent(),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("OIDC: failed to create session for user", logger.KV{
|
||||
"ip": c.RealIP(),
|
||||
"ua": c.Request().UserAgent(),
|
||||
"user_id": user.ID,
|
||||
"error": err,
|
||||
})
|
||||
return handleOIDCError(c, "OIDC: Unable to create session")
|
||||
}
|
||||
|
||||
// Set session cookie and redirect to dashboard
|
||||
h.servs.AuthService.SetSessionCookie(c, session.DecryptedToken)
|
||||
return c.Redirect(http.StatusSeeOther, "/dashboard")
|
||||
}
|
||||
|
||||
// handleOIDCError sends an OIDC error response as an HTMX toast if the request is from HTMX, or redirects to the login page with an error message for regular browser requests.
|
||||
func handleOIDCError(c echo.Context, message string) error {
|
||||
// Check if it's an HTMX request
|
||||
if c.Request().Header.Get("HX-Request") != "" {
|
||||
return respondhtmx.ToastError(c, message)
|
||||
}
|
||||
|
||||
// For regular browser requests, redirect to login with error parameter
|
||||
return c.Redirect(http.StatusSeeOther, "/auth/login?error="+url.QueryEscape(message))
|
||||
}
|
||||
Reference in New Issue
Block a user