From ac6c4f06b49b28eb8bda334dde752819b2971f14 Mon Sep 17 00:00:00 2001 From: NicolasBernardes Date: Thu, 12 Mar 2026 19:44:09 -0300 Subject: [PATCH 1/2] feat: add Casdoor SSO integration as opt-in auth provider Integrate Casdoor as an external identity provider while keeping built-in auth as the default. When auth.provider=casdoor, login and registration redirect to Casdoor via OAuth 2.0, and a callback handler creates/links local users by email. - Add CasdoorConfig and AuthConfig to configuration system - Add godotenv for .env file loading with .env.example template - Add Casdoor Go SDK service wrapper (signin/signup URLs, code exchange) - Add OAuth callback handler with find-or-create user flow - Redirect login/register to Casdoor with Inertia-compatible external redirects - Use SameSite=Lax for cookies when Casdoor is active (required for OAuth) - Pass auth provider to frontend via shared Inertia props - Show "managed by identity provider" on password settings when using Casdoor - Add Docker Compose for local Casdoor development - Add casdoor/README.md with setup instructions - Add .env to .gitignore, .env.example for developer reference Co-Authored-By: Claude Opus 4.6 --- .env.example | 12 +++ .gitignore | 1 + casdoor/README.md | 66 +++++++++++++ config/config.go | 30 ++++++ config/config.yaml | 10 ++ docker-compose.yml | 9 ++ go.mod | 7 ++ go.sum | 22 +++++ pkg/handlers/auth.go | 24 +++++ pkg/handlers/casdoor.go | 112 +++++++++++++++++++++++ pkg/handlers/router.go | 8 +- pkg/middleware/inertia_props.go | 10 +- pkg/routenames/names.go | 1 + pkg/services/casdoor.go | 88 ++++++++++++++++++ pkg/services/container.go | 12 +++ resources/js/Pages/Settings/Password.tsx | 23 ++++- resources/js/types/global.d.ts | 1 + 17 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 casdoor/README.md create mode 100644 docker-compose.yml create mode 100644 pkg/handlers/casdoor.go create mode 100644 pkg/services/casdoor.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f104dc0 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Auth Provider Configuration +# Set to "casdoor" to use Casdoor SSO, or "built-in" (default) for built-in auth. +PAGODE_AUTH_PROVIDER=built-in + +# Casdoor Configuration (only needed when PAGODE_AUTH_PROVIDER=casdoor) +# See casdoor/README.md for setup instructions. +PAGODE_AUTH_CASDOOR_ENDPOINT=http://localhost:8100 +PAGODE_AUTH_CASDOOR_CLIENTID=your-client-id +PAGODE_AUTH_CASDOOR_CLIENTSECRET=your-client-secret +PAGODE_AUTH_CASDOOR_CERTIFICATE="-----BEGIN CERTIFICATE-----\nyour-certificate-here\n-----END CERTIFICATE-----" +PAGODE_AUTH_CASDOOR_ORGANIZATIONNAME=built-in +PAGODE_AUTH_CASDOOR_APPLICATIONNAME=pagode diff --git a/.gitignore b/.gitignore index d7d810d..96bf412 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ build/ .aider.input.history .aider.tags.cache.v4/ static/chat-uploads/ +.env diff --git a/casdoor/README.md b/casdoor/README.md new file mode 100644 index 0000000..ec2c283 --- /dev/null +++ b/casdoor/README.md @@ -0,0 +1,66 @@ +# Casdoor Integration + +Pagode supports [Casdoor](https://casdoor.org) as an opt-in external authentication provider. When enabled, login and registration are handled by Casdoor via OAuth 2.0, while Pagode manages local sessions and user data. + +## Quick Start + +### 1. Start Casdoor + +```bash +docker compose up -d casdoor +``` + +Casdoor will be available at http://localhost:8100. +Default credentials: `admin` / `123` + +### 2. Configure a Pagode Application in Casdoor + +1. Open http://localhost:8100 and log in +2. Go to **Applications** > **Add** +3. Set **Name**: `pagode` +4. Set **Organization**: `built-in` +5. Set **Redirect URL**: `http://localhost:8000/auth/casdoor/callback` +6. Copy the **Client ID** and **Client Secret** +7. Save + +### 3. Get the Certificate + +1. Go to **Certs** in the Casdoor admin panel +2. Find the `cert-built-in` certificate +3. Copy the certificate content (the public key) + +### 4. Configure Pagode + +Set the following environment variables (or update `config/config.yaml`): + +```bash +export PAGODE_AUTH_PROVIDER=casdoor +export PAGODE_AUTH_CASDOOR_ENDPOINT=http://localhost:8100 +export PAGODE_AUTH_CASDOOR_CLIENTID= +export PAGODE_AUTH_CASDOOR_CLIENTSECRET= +export PAGODE_AUTH_CASDOOR_CERTIFICATE= +export PAGODE_AUTH_CASDOOR_ORGANIZATIONNAME=built-in +export PAGODE_AUTH_CASDOOR_APPLICATIONNAME=pagode +``` + +### 5. Run Pagode + +```bash +make run +``` + +Login and registration will now redirect to Casdoor. + +## Production + +In production, run Casdoor as a separate service (Docker, Kubernetes, or bare metal). See the [Casdoor deployment docs](https://casdoor.org/docs/basic/server-installation) for details. + +Update the environment variables to point to your production Casdoor instance. + +## How It Works + +- When `auth.provider=casdoor`, the login/register pages redirect to Casdoor +- After authentication, Casdoor redirects back with an authorization code +- Pagode exchanges the code for a JWT token and extracts the user's email/name +- A local user is created (or linked by email) and a session is established +- All downstream features (dashboard, payments, chat, admin) work unchanged diff --git a/config/config.go b/config/config.go index 2ef3b35..5d40737 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/joho/godotenv" "github.com/spf13/viper" ) @@ -58,6 +59,7 @@ type ( Tasks TasksConfig Mail MailConfig Payment PaymentConfig + Auth AuthConfig Chat ChatConfig } @@ -142,6 +144,22 @@ type ( Currency string } + // AuthConfig stores the authentication configuration. + AuthConfig struct { + Provider string + Casdoor CasdoorConfig + } + + // CasdoorConfig stores the Casdoor-specific configuration. + CasdoorConfig struct { + Endpoint string + ClientId string + ClientSecret string + Certificate string + OrganizationName string + ApplicationName string + } + // ChatConfig stores the chat configuration. ChatConfig struct { Enabled bool @@ -160,6 +178,9 @@ type ( func GetConfig() (Config, error) { var c Config + // Load .env file if present (does not override existing env vars). + _ = godotenv.Load() + // Load the config file. viper.SetConfigName("config") viper.SetConfigType("yaml") @@ -173,6 +194,15 @@ func GetConfig() (Config, error) { viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + // Explicitly bind Casdoor env vars so Viper picks them up. + viper.BindEnv("auth.provider") + viper.BindEnv("auth.casdoor.endpoint") + viper.BindEnv("auth.casdoor.clientid") + viper.BindEnv("auth.casdoor.clientsecret") + viper.BindEnv("auth.casdoor.certificate") + viper.BindEnv("auth.casdoor.organizationname") + viper.BindEnv("auth.casdoor.applicationname") + if err := viper.ReadInConfig(); err != nil { return c, err } diff --git a/config/config.yaml b/config/config.yaml index 885382e..f5f9fc4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -62,6 +62,16 @@ payment: webhookSecret: "whsec_your_webhook_secret_here" currency: "usd" +auth: + provider: "built-in" + casdoor: + endpoint: "" + clientId: "" + clientSecret: "" + certificate: "" + organizationName: "" + applicationName: "" + chat: enabled: true defaultRoom: "general" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d354a4a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.8" + +services: + casdoor: + image: casbin/casdoor-all-in-one + container_name: pagode-casdoor + ports: + - "8100:8000" + restart: unless-stopped diff --git a/go.mod b/go.mod index b7f9c9b..c1f5126 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/casdoor/casdoor-go-sdk v1.44.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dolthub/maphash v0.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -39,12 +40,15 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -64,10 +68,13 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.13.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.42.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c4e7d0f..b980763 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/casdoor/casdoor-go-sdk v1.44.0 h1:tKxzgSvgPAaotjywBA9rXvi0GUHSfCMpHQDsXnAyFls= +github.com/casdoor/casdoor-go-sdk v1.44.0/go.mod h1:7aXOpfl6s5M5/yjHgIGuPBwdHeSCV/jha1iYsnE1MNc= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= @@ -40,8 +42,15 @@ github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -59,6 +68,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -139,6 +150,7 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -150,6 +162,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -183,6 +197,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -204,6 +219,13 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index a862d45..21096ff 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -30,6 +30,7 @@ type Auth struct { mail *services.MailClient orm *ent.Client Inertia *inertia.Inertia + casdoor *services.CasdoorClient } type RegisterForm struct { @@ -68,6 +69,7 @@ func (h *Auth) Init(c *services.Container) error { h.auth = c.Auth h.mail = c.Mail h.Inertia = c.Inertia + h.casdoor = c.Casdoor return nil } @@ -92,6 +94,17 @@ func (h *Auth) Routes(g *echo.Group) { } func (h *Auth) LoginPage(ctx echo.Context) error { + if h.casdoor != nil { + callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) + signinURL := h.casdoor.GetSigninURL(callbackURL, "pagode") + // Use Inertia location for external redirect so it works with Inertia AJAX requests. + if ctx.Request().Header.Get("X-Inertia") != "" { + ctx.Response().Header().Set("X-Inertia-Location", signinURL) + return ctx.NoContent(http.StatusConflict) + } + return ctx.Redirect(http.StatusSeeOther, signinURL) + } + canResetPassword := true err := h.Inertia.Render( @@ -176,12 +189,23 @@ func (h *Auth) Logout(ctx echo.Context) error { } else { msg.Danger(ctx, "An error occurred. Please try again.") } + return redirect.New(ctx). Route(routenames.Welcome). Go() } func (h *Auth) RegisterPage(ctx echo.Context) error { + if h.casdoor != nil { + callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) + signupURL := h.casdoor.GetSignupURL(callbackURL, "pagode") + if ctx.Request().Header.Get("X-Inertia") != "" { + ctx.Response().Header().Set("X-Inertia-Location", signupURL) + return ctx.NoContent(http.StatusConflict) + } + return ctx.Redirect(http.StatusSeeOther, signupURL) + } + err := h.Inertia.Render( ctx.Response().Writer, ctx.Request(), diff --git a/pkg/handlers/casdoor.go b/pkg/handlers/casdoor.go new file mode 100644 index 0000000..ac5396f --- /dev/null +++ b/pkg/handlers/casdoor.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "strings" + + "github.com/labstack/echo/v4" + "github.com/occult/pagode/ent" + "github.com/occult/pagode/ent/user" + "github.com/occult/pagode/pkg/log" + "github.com/occult/pagode/pkg/msg" + "github.com/occult/pagode/pkg/redirect" + "github.com/occult/pagode/pkg/routenames" + "github.com/occult/pagode/pkg/services" + inertia "github.com/romsar/gonertia/v2" +) + +type Casdoor struct { + auth *services.AuthClient + casdoor *services.CasdoorClient + orm *ent.Client + Inertia *inertia.Inertia +} + +func init() { + Register(new(Casdoor)) +} + +func (h *Casdoor) Init(c *services.Container) error { + h.auth = c.Auth + h.casdoor = c.Casdoor + h.orm = c.ORM + h.Inertia = c.Inertia + return nil +} + +func (h *Casdoor) Routes(g *echo.Group) { + if h.casdoor == nil { + return + } + g.GET("/auth/casdoor/callback", h.Callback).Name = routenames.CasdoorCallback +} + +func (h *Casdoor) Callback(ctx echo.Context) error { + code := ctx.QueryParam("code") + state := ctx.QueryParam("state") + + if code == "" { + msg.Danger(ctx, "Invalid authentication response.") + return redirect.New(ctx).Route(routenames.Welcome).Go() + } + + email, name, err := h.casdoor.ExchangeCodeAndGetUser(code, state) + if err != nil { + log.Ctx(ctx).Error("casdoor auth failed", "error", err) + msg.Danger(ctx, "Authentication failed. Please try again.") + return redirect.New(ctx).Route(routenames.Welcome).Go() + } + + // Find or create local user + u, err := h.orm.User. + Query(). + Where(user.Email(strings.ToLower(email))). + Only(ctx.Request().Context()) + + if err != nil { + if ent.IsNotFound(err) { + // Create a new local user with a random placeholder password + placeholder, _ := randomPassword(64) + u, err = h.orm.User. + Create(). + SetName(name). + SetEmail(strings.ToLower(email)). + SetPassword(placeholder). + SetVerified(true). + Save(ctx.Request().Context()) + if err != nil { + log.Ctx(ctx).Error("failed to create user from casdoor", "error", err) + msg.Danger(ctx, "Failed to create account. Please try again.") + return redirect.New(ctx).Route(routenames.Welcome).Go() + } + } else { + log.Ctx(ctx).Error("failed to query user", "error", err) + msg.Danger(ctx, "An error occurred. Please try again.") + return redirect.New(ctx).Route(routenames.Welcome).Go() + } + } + + // Mark as verified if not already + if !u.Verified { + u, _ = u.Update().SetVerified(true).Save(ctx.Request().Context()) + } + + // Log the user in using the existing session mechanism + if err := h.auth.Login(ctx, u.ID); err != nil { + log.Ctx(ctx).Error("failed to login casdoor user", "error", err) + msg.Danger(ctx, "Login failed. Please try again.") + return redirect.New(ctx).Route(routenames.Welcome).Go() + } + + msg.Success(ctx, "You have been logged in successfully.") + return redirect.New(ctx).Route(routenames.Dashboard).Go() +} + +func randomPassword(length int) (string, error) { + b := make([]byte, (length/2)+1) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b)[:length], nil +} diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index f4f38f0..330da4e 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -36,7 +36,11 @@ func BuildRouter(c *services.Container) error { // Create a cookie store for session data. cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey)) cookieStore.Options.HttpOnly = true - cookieStore.Options.SameSite = http.SameSiteStrictMode + sameSite := http.SameSiteStrictMode + if c.Config.Auth.Provider == "casdoor" { + sameSite = http.SameSiteLaxMode + } + cookieStore.Options.SameSite = sameSite g.Use( echomw.RemoveTrailingSlashWithConfig(echomw.TrailingSlashConfig{ @@ -59,7 +63,7 @@ func BuildRouter(c *services.Container) error { CookieName: "XSRF-TOKEN", // this sets the cookie CookiePath: "/", // make it accessible app-wide CookieHTTPOnly: false, // must be false so JS (Axios) can read it - CookieSameSite: http.SameSiteStrictMode, + CookieSameSite: sameSite, ContextKey: context.CSRFKey, ErrorHandler: func(err error, ctx echo.Context) error { // Clear the stale CSRF cookie so a fresh token is set on redirect. diff --git a/pkg/middleware/inertia_props.go b/pkg/middleware/inertia_props.go index 5c1eb6b..c712aa9 100644 --- a/pkg/middleware/inertia_props.go +++ b/pkg/middleware/inertia_props.go @@ -2,6 +2,7 @@ package middleware import ( "github.com/labstack/echo/v4" + "github.com/occult/pagode/config" "github.com/occult/pagode/pkg/context" "github.com/occult/pagode/pkg/msg" "github.com/romsar/gonertia/v2" @@ -27,11 +28,18 @@ func InertiaProps() echo.MiddlewareFunc { } } + // Determine auth provider + authProvider := "built-in" + if cfg, ok := ctx.Get(context.ConfigKey).(*config.Config); ok && cfg.Auth.Provider != "" { + authProvider = cfg.Auth.Provider + } + // Set Inertia props newCtx := gonertia.SetProps(ctx.Request().Context(), map[string]any{ "flash": flash, "auth": map[string]any{ - "user": user, + "user": user, + "provider": authProvider, }, }) diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index c617252..5eb6c33 100644 --- a/pkg/routenames/names.go +++ b/pkg/routenames/names.go @@ -53,6 +53,7 @@ const ( ChatBanUser = "chat.ban" ChatUnbanUser = "chat.unban" ChatDeleteRoom = "chat.room.delete" + CasdoorCallback = "casdoor.callback" ) func AdminEntityList(entityTypeName string) string { diff --git a/pkg/services/casdoor.go b/pkg/services/casdoor.go new file mode 100644 index 0000000..d651a28 --- /dev/null +++ b/pkg/services/casdoor.go @@ -0,0 +1,88 @@ +package services + +import ( + "fmt" + "net/url" + + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" + "github.com/occult/pagode/config" +) + +// CasdoorClient wraps the Casdoor SDK client. +type CasdoorClient struct { + client *casdoorsdk.Client + endpoint string + appName string +} + +// NewCasdoorClient creates a new Casdoor client from configuration. +func NewCasdoorClient(cfg *config.CasdoorConfig) *CasdoorClient { + client := casdoorsdk.NewClient( + cfg.Endpoint, + cfg.ClientId, + cfg.ClientSecret, + cfg.Certificate, + cfg.OrganizationName, + cfg.ApplicationName, + ) + + return &CasdoorClient{ + client: client, + endpoint: cfg.Endpoint, + appName: cfg.ApplicationName, + } +} + +// GetSigninURL returns the Casdoor login page URL. +func (c *CasdoorClient) GetSigninURL(redirectURI, state string) string { + return fmt.Sprintf( + "%s/login/oauth/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=openid+profile+email&state=%s", + c.endpoint, + url.QueryEscape(c.client.ClientId), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) +} + +// GetSignupURL returns the Casdoor signup page URL. +func (c *CasdoorClient) GetSignupURL(redirectURI, state string) string { + return fmt.Sprintf( + "%s/signup/%s?redirect_uri=%s&state=%s", + c.endpoint, + url.PathEscape(c.appName), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) +} + +// GetLogoutURL returns the Casdoor logout URL. +func (c *CasdoorClient) GetLogoutURL() string { + return fmt.Sprintf("%s/api/logout", c.endpoint) +} + +// ExchangeCodeAndGetUser exchanges an authorization code for a token and returns the user's email and name. +func (c *CasdoorClient) ExchangeCodeAndGetUser(code, state string) (email, name string, err error) { + token, err := c.client.GetOAuthToken(code, state) + if err != nil { + return "", "", fmt.Errorf("failed to exchange code for token: %w", err) + } + + claims, err := c.client.ParseJwtToken(token.AccessToken) + if err != nil { + return "", "", fmt.Errorf("failed to parse JWT token: %w", err) + } + + email = claims.Email + name = claims.Name + if name == "" { + name = claims.DisplayName + } + if name == "" { + name = claims.User.Name + } + if email == "" { + return "", "", fmt.Errorf("casdoor token missing email claim") + } + + return email, name, nil +} diff --git a/pkg/services/container.go b/pkg/services/container.go index b74d89e..24a53dd 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -73,6 +73,9 @@ type Container struct { // Chat stores the chat room manager. Chat *chat.RoomManager + // Casdoor stores the Casdoor client (nil when using built-in auth). + Casdoor *CasdoorClient + // Inertia for React Inertia *inertia.Inertia @@ -91,6 +94,7 @@ func NewContainer() *Container { c.initFiles() c.initORM() c.initAuth() + c.initCasdoor() c.initMail() c.initTasks() c.initPayment() @@ -233,6 +237,14 @@ func (c *Container) initAuth() { c.Auth = NewAuthClient(c.Config, c.ORM) } +// initCasdoor initializes the Casdoor client if configured. +func (c *Container) initCasdoor() { + if c.Config.Auth.Provider != "casdoor" { + return + } + c.Casdoor = NewCasdoorClient(&c.Config.Auth.Casdoor) +} + // initMail initialize the mail client. func (c *Container) initMail() { var err error diff --git a/resources/js/Pages/Settings/Password.tsx b/resources/js/Pages/Settings/Password.tsx index 95e395e..745d363 100644 --- a/resources/js/Pages/Settings/Password.tsx +++ b/resources/js/Pages/Settings/Password.tsx @@ -1,6 +1,7 @@ import { type BreadcrumbItem } from "@/types"; +import { SharedProps } from "@/types/global"; import { Transition } from "@headlessui/react"; -import { Head, useForm } from "@inertiajs/react"; +import { Head, useForm, usePage } from "@inertiajs/react"; import { FormEventHandler, useRef } from "react"; import { Button } from "@/components/ui/button"; @@ -19,6 +20,7 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function Password() { + const { auth } = usePage().props; const passwordInput = useRef(null); const currentPasswordInput = useRef(null); @@ -50,6 +52,25 @@ export default function Password() { }); }; + if (auth.provider === "casdoor") { + return ( + + + +
+ +

+ To change your password, please visit your Casdoor account settings. +

+
+
+
+ ); + } + return ( diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 87f49e8..e25b3f5 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -11,5 +11,6 @@ export type SharedProps = { flash: FlashMessages; auth: { user: User | null; + provider: "built-in" | "casdoor"; }; }; From 1006cbe27ae7db7545f98344dfb928fd4681b468 Mon Sep 17 00:00:00 2001 From: NicolasBernardes Date: Thu, 19 Mar 2026 17:40:51 -0300 Subject: [PATCH 2/2] refactor: remove built-in auth, make Casdoor the sole auth provider - Remove all built-in login/register/password-reset/email-verification flows - Login and Register now always redirect to Casdoor SSO - Logout clears local session and redirects to Casdoor logout - Strip AuthClient down to Login/Logout/GetAuthenticatedUser (session only) - Remove LoadValidPasswordToken middleware and password-related route names - Remove password change from profile settings (managed by Casdoor) - Simplify account deletion (no password confirmation needed) - Delete all frontend Auth pages (Login, Register, ForgotPassword, etc.) - Add graceful error handling when Casdoor is unreachable - Always use SameSite=Lax for cookies (required for OAuth redirects) - Update tests to match simplified auth surface Co-Authored-By: Claude Opus 4.6 --- cmd/admin/main.go | 13 +- config/config.go | 6 +- config/config.yaml | 1 - pkg/handlers/auth.go | 530 ++------------------ pkg/handlers/profile.go | 63 --- pkg/handlers/router.go | 8 +- pkg/middleware/auth.go | 44 -- pkg/middleware/auth_test.go | 39 -- pkg/middleware/inertia_props.go | 9 +- pkg/routenames/names.go | 14 +- pkg/services/auth.go | 129 ----- pkg/services/auth_test.go | 110 ---- pkg/services/casdoor.go | 13 + pkg/services/container.go | 7 +- pkg/ui/emails/auth.go | 8 +- pkg/ui/layouts/auth.go | 1 - pkg/ui/layouts/primary.go | 1 - resources/js/Pages/Auth/ConfirmPassword.tsx | 62 --- resources/js/Pages/Auth/ForgotPassword.tsx | 70 --- resources/js/Pages/Auth/Login.tsx | 129 ----- resources/js/Pages/Auth/Register.tsx | 118 ----- resources/js/Pages/Auth/ResetPassword.tsx | 115 ----- resources/js/Pages/Auth/VerifyEmail.tsx | 48 -- resources/js/Pages/Settings/Password.tsx | 134 +---- resources/js/components/DeleteUser.tsx | 54 +- resources/js/types/global.d.ts | 2 +- 26 files changed, 75 insertions(+), 1653 deletions(-) delete mode 100644 resources/js/Pages/Auth/ConfirmPassword.tsx delete mode 100644 resources/js/Pages/Auth/ForgotPassword.tsx delete mode 100644 resources/js/Pages/Auth/Login.tsx delete mode 100644 resources/js/Pages/Auth/Register.tsx delete mode 100644 resources/js/Pages/Auth/ResetPassword.tsx delete mode 100644 resources/js/Pages/Auth/VerifyEmail.tsx diff --git a/cmd/admin/main.go b/cmd/admin/main.go index dc364c1..2fe1eb1 100644 --- a/cmd/admin/main.go +++ b/cmd/admin/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "flag" "fmt" "os" @@ -29,14 +31,15 @@ func main() { invalid("email is required") } - // Generate a password. - pw, err := c.Auth.RandomToken(10) - if err != nil { + // Generate a random placeholder password (Casdoor manages real passwords). + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { invalid("failed to generate a random password") } + pw := hex.EncodeToString(b) // Create the admin user. - err = c.ORM.User. + err := c.ORM.User. Create(). SetEmail(email). SetName("Admin"). @@ -52,7 +55,7 @@ func main() { fmt.Println("") fmt.Println("-- ADMIN USER CREATED --") fmt.Printf("Email: %s\n", email) - fmt.Printf("Password: %s\n", pw) + fmt.Println("Password is managed by Casdoor SSO.") fmt.Println("----") fmt.Println("") } diff --git a/config/config.go b/config/config.go index 5d40737..820a5c4 100644 --- a/config/config.go +++ b/config/config.go @@ -144,10 +144,9 @@ type ( Currency string } - // AuthConfig stores the authentication configuration. + // AuthConfig stores the authentication configuration (Casdoor SSO). AuthConfig struct { - Provider string - Casdoor CasdoorConfig + Casdoor CasdoorConfig } // CasdoorConfig stores the Casdoor-specific configuration. @@ -195,7 +194,6 @@ func GetConfig() (Config, error) { viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // Explicitly bind Casdoor env vars so Viper picks them up. - viper.BindEnv("auth.provider") viper.BindEnv("auth.casdoor.endpoint") viper.BindEnv("auth.casdoor.clientid") viper.BindEnv("auth.casdoor.clientsecret") diff --git a/config/config.yaml b/config/config.yaml index f5f9fc4..d1f00c1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -63,7 +63,6 @@ payment: currency: "usd" auth: - provider: "built-in" casdoor: endpoint: "" clientId: "" diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 21096ff..ea9288a 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -1,25 +1,16 @@ package handlers import ( - "fmt" "net/http" - "strconv" - "strings" - "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/occult/pagode/config" - "github.com/occult/pagode/ent" - "github.com/occult/pagode/ent/user" - "github.com/occult/pagode/pkg/context" - "github.com/occult/pagode/pkg/form" "github.com/occult/pagode/pkg/log" "github.com/occult/pagode/pkg/middleware" "github.com/occult/pagode/pkg/msg" "github.com/occult/pagode/pkg/redirect" "github.com/occult/pagode/pkg/routenames" "github.com/occult/pagode/pkg/services" - "github.com/occult/pagode/pkg/ui" inertia "github.com/romsar/gonertia/v2" ) @@ -27,36 +18,8 @@ import ( type Auth struct { config *config.Config auth *services.AuthClient - mail *services.MailClient - orm *ent.Client - Inertia *inertia.Inertia casdoor *services.CasdoorClient -} - -type RegisterForm struct { - Name string `form:"name" validate:"required"` - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password_confirmation" validate:"required,eqfield=Password"` - form.Submission -} - -type LoginForm struct { - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - form.Submission -} - -type ForgotPassword struct { - Email string `form:"email" validate:"required,email"` - form.Submission -} - -type ResetPassword struct { - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password_confirmation" validate:"required,eqfield=Password"` - - form.Submission + Inertia *inertia.Inertia } func init() { @@ -65,122 +28,52 @@ func init() { func (h *Auth) Init(c *services.Container) error { h.config = c.Config - h.orm = c.ORM h.auth = c.Auth - h.mail = c.Mail - h.Inertia = c.Inertia h.casdoor = c.Casdoor + h.Inertia = c.Inertia return nil } func (h *Auth) Routes(g *echo.Group) { g.GET("/logout", h.Logout, middleware.RequireAuthentication).Name = routenames.Logout - g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail noAuth := g.Group("/user", middleware.RequireNoAuthentication) noAuth.GET("/login", h.LoginPage).Name = routenames.Login - noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit noAuth.GET("/register", h.RegisterPage).Name = routenames.Register - noAuth.POST("/register", h.RegisterSubmit).Name = routenames.RegisterSubmit - noAuth.GET("/password", h.ForgotPasswordPage).Name = routenames.ForgotPassword - noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routenames.ForgotPasswordSubmit - - resetGroup := noAuth.Group("/password/reset", - middleware.LoadUser(h.orm), - middleware.LoadValidPasswordToken(h.auth), - ) - resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routenames.ResetPassword - resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routenames.ResetPasswordSubmit } func (h *Auth) LoginPage(ctx echo.Context) error { - if h.casdoor != nil { - callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) - signinURL := h.casdoor.GetSigninURL(callbackURL, "pagode") - // Use Inertia location for external redirect so it works with Inertia AJAX requests. - if ctx.Request().Header.Get("X-Inertia") != "" { - ctx.Response().Header().Set("X-Inertia-Location", signinURL) - return ctx.NoContent(http.StatusConflict) - } - return ctx.Redirect(http.StatusSeeOther, signinURL) + if !h.casdoor.IsReachable() { + log.Ctx(ctx).Error("authentication service unreachable") + msg.Danger(ctx, "Authentication is temporarily unavailable. Please try again later.") + return redirect.New(ctx).Route(routenames.Welcome).Go() } - canResetPassword := true + callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) + signinURL := h.casdoor.GetSigninURL(callbackURL, "pagode") - err := h.Inertia.Render( - ctx.Response().Writer, - ctx.Request(), - "Auth/Login", - inertia.Props{ - "canResetPassword": canResetPassword, - }, - ) - if err != nil { - handleServerErr(ctx.Response().Writer, err) - return err + if ctx.Request().Header.Get("X-Inertia") != "" { + ctx.Response().Header().Set("X-Inertia-Location", signinURL) + return ctx.NoContent(http.StatusConflict) } - - return nil + return ctx.Redirect(http.StatusSeeOther, signinURL) } -func (h *Auth) LoginSubmit(ctx echo.Context) error { - w := ctx.Response().Writer - r := ctx.Request() - - var input LoginForm - - uriLogin := ctx.Echo().Reverse(routenames.Login) - - authFailed := func() error { - input.SetFieldError("Email", "") - input.SetFieldError("Password", "") - msg.Danger(ctx, "Invalid credentials. Please try again.") - h.Inertia.Redirect(w, r, uriLogin) - return nil - } - - err := form.Submit(ctx, &input) - - switch err.(type) { - case nil: - case validator.ValidationErrors: - return h.LoginPage(ctx) - default: - return err - } - - // Attempt to load the user. - u, err := h.orm.User. - Query(). - Where(user.Email(strings.ToLower(input.Email))). - Only(ctx.Request().Context()) - - switch err.(type) { - case *ent.NotFoundError: - return authFailed() - case nil: - default: - return fail(err, "error querying user during login", h.Inertia, ctx) +func (h *Auth) RegisterPage(ctx echo.Context) error { + if !h.casdoor.IsReachable() { + log.Ctx(ctx).Error("authentication service unreachable") + msg.Danger(ctx, "Authentication is temporarily unavailable. Please try again later.") + return redirect.New(ctx).Route(routenames.Welcome).Go() } - // Check if the password is correct. - err = h.auth.CheckPassword(input.Password, u.Password) - if err != nil { - return authFailed() - } + callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) + signupURL := h.casdoor.GetSignupURL(callbackURL, "pagode") - // Log the user in. - err = h.auth.Login(ctx, u.ID) - if err != nil { - return fail(err, "unable to log in user", h.Inertia, ctx) + if ctx.Request().Header.Get("X-Inertia") != "" { + ctx.Response().Header().Set("X-Inertia-Location", signupURL) + return ctx.NoContent(http.StatusConflict) } - - uriDashboard := ctx.Echo().Reverse(routenames.Dashboard) - - msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) - - h.Inertia.Redirect(w, r, uriDashboard) - return nil + return ctx.Redirect(http.StatusSeeOther, signupURL) } func (h *Auth) Logout(ctx echo.Context) error { @@ -190,376 +83,15 @@ func (h *Auth) Logout(ctx echo.Context) error { msg.Danger(ctx, "An error occurred. Please try again.") } - return redirect.New(ctx). - Route(routenames.Welcome). - Go() -} - -func (h *Auth) RegisterPage(ctx echo.Context) error { - if h.casdoor != nil { - callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) - signupURL := h.casdoor.GetSignupURL(callbackURL, "pagode") - if ctx.Request().Header.Get("X-Inertia") != "" { - ctx.Response().Header().Set("X-Inertia-Location", signupURL) - return ctx.NoContent(http.StatusConflict) + // Redirect to Casdoor logout to clear the SSO session as well. + if h.casdoor.IsReachable() { + logoutURL := h.casdoor.GetLogoutURL() + if logoutURL != "" { + return ctx.Redirect(http.StatusSeeOther, logoutURL) } - return ctx.Redirect(http.StatusSeeOther, signupURL) - } - - err := h.Inertia.Render( - ctx.Response().Writer, - ctx.Request(), - "Auth/Register", - inertia.Props{ - "text": "Teste", - }, - ) - if err != nil { - handleServerErr(ctx.Response().Writer, err) - return err - } - - return nil -} - -func (h *Auth) RegisterSubmit(ctx echo.Context) error { - w := ctx.Response().Writer - r := ctx.Request() - - var input RegisterForm - err := form.Submit(ctx, &input) - - uriLogin := ctx.Echo().Reverse(routenames.Login) - - log.Ctx(ctx).Info("🔍 Register form submitted", "input", input) - - // Validate submitted form data - switch err.(type) { - case nil: - log.Ctx(ctx).Info("Form submitted successfully", "data", input) - - case validator.ValidationErrors: - msg.Danger(ctx, "Please fill in all required fields correctly.") - h.Inertia.Redirect(w, r, uriLogin) - return nil - - default: - msg.Danger(ctx, "Something went wrong. Please try again.") - h.Inertia.Redirect(w, r, uriLogin) - return nil - } - - // Attempt to create the user - u, err := h.orm.User. - Create(). - SetName(input.Name). - SetEmail(input.Email). - SetPassword(input.Password). - Save(r.Context()) - - switch err.(type) { - case nil: - log.Ctx(ctx).Info("✅ User created", - "user_name", input.Name, - "user_email", input.Email, - ) - case *ent.ConstraintError: - msg.Warning(ctx, "A user with this email address already exists. Please log in.") - h.Inertia.Redirect(w, r, uriLogin) - return nil - default: - return fail(err, "unable to create user", h.Inertia, ctx) - } - - // Try to log the user in - err = h.auth.Login(ctx, u.ID) - if err != nil { - msg.Info(ctx, "Your account has been created.") - h.Inertia.Redirect(w, r, uriLogin) - return nil } - msg.Success(ctx, "Your account has been created. You are now logged in.") - - // Send verification email - err = h.sendVerificationEmail(ctx, u) - if err != nil { - log.Ctx(ctx).Error("unable to send email verification", - "user_id", u.ID, - "error", err, - ) - } - - uriDashboard := ctx.Echo().Reverse(routenames.Dashboard) - - h.Inertia.Redirect(w, r, uriDashboard) - return nil -} - -func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) error { - token, err := h.auth.GenerateEmailVerificationToken(usr.Email) - if err != nil { - log.Ctx(ctx).Error("unable to generate email verification token", - "user_id", usr.ID, - "error", err, - ) - return fail(err, "failed to generate verification token", h.Inertia, ctx) - } - - url := ui.NewRequest(ctx). - Url(routenames.VerifyEmail, token) - - subject := "Confirm your email address" - html := fmt.Sprintf(` -

Hello %s,

-

Thank you for signing up. Please verify your email address by clicking the link below:

-

Verify Email

-

If you didn’t create an account, you can ignore this email.

- `, usr.Name, url) - - if err := h.mail.Compose(). - To(usr.Email). - Subject(subject). - Body(html). - Send(ctx); err != nil { - - log.Ctx(ctx).Error("unable to send email verification token", - "user_id", usr.ID, "error", err) - return fail(err, "failed to send verification email", h.Inertia, ctx) - } - - msg.Info(ctx, "An email was sent to you to verify your email address.") - return nil -} - -func (h *Auth) VerifyEmail(ctx echo.Context) error { - var usr *ent.User - - w := ctx.Response().Writer - r := ctx.Request() - - uriWelcome := ctx.Echo().Reverse(routenames.Welcome) - - uriForgotPassword := ctx.Echo().Reverse(routenames.ForgotPassword) - - // Validate the token. - token := ctx.Param("token") - email, err := h.auth.ValidateEmailVerificationToken(token) - if err != nil { - msg.Warning(ctx, "The link is either invalid or has expired.") - h.Inertia.Redirect(w, r, uriForgotPassword) - } - - // Check if it matches the authenticated user. - if u := ctx.Get(context.AuthenticatedUserKey); u != nil { - authUser := u.(*ent.User) - - if authUser.Email == email { - usr = authUser - } - } - - // Query to find a matching user, if needed. - if usr == nil { - usr, err = h.orm.User. - Query(). - Where(user.Email(email)). - Only(ctx.Request().Context()) - if err != nil { - return fail(err, "query failed loading email verification token user", h.Inertia, ctx) - } - } - - // Verify the user, if needed. - if !usr.Verified { - usr, err = usr. - Update(). - SetVerified(true). - Save(ctx.Request().Context()) - if err != nil { - return fail(err, "failed to set user as verified", h.Inertia, ctx) - } - } - - msg.Success(ctx, "Your email has been successfully verified.") - - h.Inertia.Redirect(w, r, uriWelcome) - return nil -} - -func (h *Auth) ForgotPasswordPage(ctx echo.Context) error { - err := h.Inertia.Render( - ctx.Response().Writer, - ctx.Request(), - "Auth/ForgotPassword", - ) - if err != nil { - handleServerErr(ctx.Response().Writer, err) - return err - } - - return nil -} - -func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { - var input ForgotPassword - - w := ctx.Response().Writer - r := ctx.Request() - - uriForgotPassword := ctx.Echo().Reverse(routenames.ForgotPassword) - - succeed := func() error { - form.Clear(ctx) - msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.") - h.Inertia.Redirect(w, r, uriForgotPassword) - return nil - } - - err := form.Submit(ctx, &input) - switch err.(type) { - case nil: - case validator.ValidationErrors: - return h.ForgotPasswordPage(ctx) - default: - return fail(err, "form submission error on forgot password", h.Inertia, ctx) - } - - // Attempt to load the user. - u, err := h.orm.User. - Query(). - Where(user.Email(strings.ToLower(input.Email))). - Only(ctx.Request().Context()) - - switch err.(type) { - case *ent.NotFoundError: - // We return success without revealing the email does not exist. This prevents user enumeration. - return succeed() - case nil: - default: - return fail(err, "error querying user during forgot password", h.Inertia, ctx) - } - - // Generate the token. - token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID) - if err != nil { - return fail(err, "error generating password reset token", h.Inertia, ctx) - } - - log.Ctx(ctx).Info("generated password reset token", - "user_id", u.ID, - ) - - url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token) - - subject := "Reset your password" - html := fmt.Sprintf(` -

Hello %s,

-

To reset your password go to the link below:

-

Reset Password

-

If you didn’t request a password update, you can ignore this email.

- `, u.Name, h.config.App.Host+url) - - if err := h.mail.Compose(). - To(u.Email). - Subject(subject). - Body(html). - Send(ctx); err != nil { - - log.Ctx(ctx).Error("unable to send password reset email", - "user_id", u.ID, - "error", err, - ) - return fail(err, "failed to send password reset email", h.Inertia, ctx) - } - - return succeed() -} - -func (h *Auth) ResetPasswordPage(ctx echo.Context) error { - userIDStr := ctx.Param("user") - passwordTokenID := ctx.Param("password_token") - token := ctx.Param("token") - - userID, err := strconv.Atoi(userIDStr) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") - } - - u, err := h.orm.User. - Query(). - Where(user.IDEQ(userID)). - Only(ctx.Request().Context()) - if err != nil { - if ent.IsNotFound(err) { - return echo.NewHTTPError(http.StatusNotFound, "User not found") - } - return fail(err, "error loading user in ResetPasswordPage", h.Inertia, ctx) - } - - props := map[string]any{ - "token": token, - "userID": userIDStr, - "passwordTokenID": passwordTokenID, - "email": u.Email, - } - - err = h.Inertia.Render(ctx.Response().Writer, ctx.Request(), "Auth/ResetPassword", props) - if err != nil { - handleServerErr(ctx.Response().Writer, err) - return err - } - - return nil -} - -func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { - var input ResetPassword - - err := form.Submit(ctx, &input) - - w := ctx.Response().Writer - r := ctx.Request() - - uriLogin := ctx.Echo().Reverse(routenames.Login) - - switch err.(type) { - case nil: - case validator.ValidationErrors: - log.Ctx(ctx).Warn("⚠️ Validation failed", "errors", err) - msg.Danger(ctx, "Please fill in the fields correctly.") - h.Inertia.Redirect(w, r, r.URL.Path) - return nil - default: - msg.Danger(ctx, "There was a problem processing your request.") - h.Inertia.Redirect(w, r, r.URL.Path) - return nil - } - - usr, ok := ctx.Get(context.UserKey).(*ent.User) - if !ok || usr == nil { - msg.Danger(ctx, "User not found.") - h.Inertia.Redirect(w, r, r.URL.Path) - return nil - } - - _, err = usr.Update(). - SetPassword(input.Password). - Save(ctx.Request().Context()) - if err != nil { - msg.Danger(ctx, "Unable to update your password. Please try again.") - h.Inertia.Redirect(w, r, r.URL.Path) - return nil - } - - err = h.auth.DeletePasswordTokens(ctx, usr.ID) - if err != nil { - msg.Danger(ctx, "Password updated, but failed to clean up tokens.") - h.Inertia.Redirect(w, r, uriLogin) - return nil - } - - msg.Success(ctx, "Your password has been updated.") - h.Inertia.Redirect(w, r, uriLogin) - return nil + return redirect.New(ctx). + Route(routenames.Welcome). + Go() } diff --git a/pkg/handlers/profile.go b/pkg/handlers/profile.go index d8a7f5d..6699781 100644 --- a/pkg/handlers/profile.go +++ b/pkg/handlers/profile.go @@ -26,18 +26,6 @@ type UpdateBasicInfoForm struct { form.Submission } -type UpdatePasswordForm struct { - CurrentPassword string `form:"current_password" validate:"required"` - Password string `form:"password" validate:"required,min=8"` - PasswordConfirmation string `form:"password_confirmation" validate:"required,eqfield=Password"` - form.Submission -} - -type DeleteAccountForm struct { - Password string `form:"password" validate:"required"` - form.Submission -} - func init() { Register(new(Profile)) } @@ -58,7 +46,6 @@ func (h *Profile) Routes(g *echo.Group) { profile.GET("/appearance", h.AppearancePage).Name = routenames.ProfileAppearance profile.GET("/password", h.PasswordPage).Name = routenames.ProfilePassword - profile.POST("/update-password", h.UpdatePassword).Name = routenames.ProfileUpdatePassword } func (h *Profile) EditPage(ctx echo.Context) error { @@ -113,56 +100,6 @@ func (h *Profile) UpdateBasicInfo(ctx echo.Context) error { return nil } -func (h *Profile) UpdatePassword(ctx echo.Context) error { - var input UpdatePasswordForm - - usr, ok := ctx.Get(context.AuthenticatedUserKey).(*ent.User) - if !ok { - msg.Danger(ctx, "You must be logged in.") - h.Inertia.Back(ctx.Response().Writer, ctx.Request()) - return nil - } - - err := form.Submit(ctx, &input) - - switch err.(type) { - case nil: - case validator.ValidationErrors: - msg.Warning(ctx, "Please fix the errors in the form and try again.") - h.Inertia.Back(ctx.Response().Writer, ctx.Request()) - return nil - default: - return err - } - - if err := h.auth.CheckPassword(input.CurrentPassword, usr.Password); err != nil { - msg.Danger(ctx, "The current password you entered is incorrect.") - h.Inertia.Back(ctx.Response().Writer, ctx.Request()) - return nil - } - - _, err = h.orm.User. - UpdateOneID(usr.ID). - SetPassword(input.Password). - Save(ctx.Request().Context()) - if err != nil { - msg.Danger(ctx, "Something went wrong while saving your new password.") - h.Inertia.Back(ctx.Response().Writer, ctx.Request()) - return nil - } - - usr, err = h.orm.User.Get(ctx.Request().Context(), usr.ID) - if err != nil { - msg.Danger(ctx, "Something went wrong while refreshing your session.") - h.Inertia.Back(ctx.Response().Writer, ctx.Request()) - return nil - } - - msg.Success(ctx, "Your password has been updated successfully.") - h.Inertia.Redirect(ctx.Response().Writer, ctx.Request(), ctx.Echo().Reverse(routenames.ProfilePassword)) - return nil -} - func (h *Profile) DeleteAccount(ctx echo.Context) error { usr := ctx.Get(context.AuthenticatedUserKey).(*ent.User) diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index 330da4e..1f4cd22 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -36,11 +36,7 @@ func BuildRouter(c *services.Container) error { // Create a cookie store for session data. cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey)) cookieStore.Options.HttpOnly = true - sameSite := http.SameSiteStrictMode - if c.Config.Auth.Provider == "casdoor" { - sameSite = http.SameSiteLaxMode - } - cookieStore.Options.SameSite = sameSite + cookieStore.Options.SameSite = http.SameSiteLaxMode g.Use( echomw.RemoveTrailingSlashWithConfig(echomw.TrailingSlashConfig{ @@ -63,7 +59,7 @@ func BuildRouter(c *services.Container) error { CookieName: "XSRF-TOKEN", // this sets the cookie CookiePath: "/", // make it accessible app-wide CookieHTTPOnly: false, // must be false so JS (Axios) can read it - CookieSameSite: sameSite, + CookieSameSite: http.SameSiteLaxMode, ContextKey: context.CSRFKey, ErrorHandler: func(err error, ctx echo.Context) error { // Clear the stale CSRF cookie so a fresh token is set on redirect. diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 923483f..8608f70 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -3,7 +3,6 @@ package middleware import ( "fmt" "net/http" - "strconv" "github.com/occult/pagode/ent" "github.com/occult/pagode/ent/paymentcustomer" @@ -42,49 +41,6 @@ func LoadAuthenticatedUser(authClient *services.AuthClient) echo.MiddlewareFunc } } -// LoadValidPasswordToken loads a valid password token entity that matches the user and token -// provided in path parameters -// If the token is invalid, the user will be redirected to the forgot password route -// This requires that the user owning the token is loaded in to context. -func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - // Extract the user parameter - if c.Get(context.UserKey) == nil { - return echo.NewHTTPError(http.StatusInternalServerError) - } - usr := c.Get(context.UserKey).(*ent.User) - - // Extract the token ID. - tokenID, err := strconv.Atoi(c.Param("password_token")) - if err != nil { - return echo.NewHTTPError(http.StatusNotFound) - } - - // Attempt to load a valid password token. - token, err := authClient.GetValidPasswordToken( - c, - usr.ID, - tokenID, - c.Param("token"), - ) - - switch err.(type) { - case nil: - c.Set(context.PasswordTokenKey, token) - return next(c) - case services.InvalidPasswordTokenError: - msg.Warning(c, "The link is either invalid or has expired. Please request a new one.") - return c.Redirect(http.StatusFound, c.Echo().Reverse(routenames.ForgotPassword)) - default: - return echo.NewHTTPError( - http.StatusInternalServerError, - fmt.Sprintf("error loading password token: %v", err), - ) - } - } - } -} // RequireAuthentication requires that the user be authenticated in order to proceed. func RequireAuthentication(next echo.HandlerFunc) echo.HandlerFunc { diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index 789f7d2..70b0d30 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -2,7 +2,6 @@ package middleware import ( goctx "context" - "fmt" "net/http" "testing" @@ -73,44 +72,6 @@ func TestRequireNoAuthentication(t *testing.T) { assert.Equal(t, http.StatusSeeOther, ctx.Response().Status) } -func TestLoadValidPasswordToken(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") - tests.InitSession(ctx) - - // Missing user context - err := tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth)) - tests.AssertHTTPErrorCode(t, err, http.StatusInternalServerError) - - // Add user and password token context but no token and expect a redirect - ctx.SetParamNames("user", "password_token") - ctx.SetParamValues(fmt.Sprintf("%d", usr.ID), "1") - _ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM)) - err = tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth)) - assert.NoError(t, err) - assert.Equal(t, http.StatusFound, ctx.Response().Status) - - // Add user context and invalid password token and expect a redirect - ctx.SetParamNames("user", "password_token", "token") - ctx.SetParamValues(fmt.Sprintf("%d", usr.ID), "1", "faketoken") - _ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM)) - err = tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth)) - assert.NoError(t, err) - assert.Equal(t, http.StatusFound, ctx.Response().Status) - - // Create a valid token - token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID) - require.NoError(t, err) - - // Add user and valid password token - ctx.SetParamNames("user", "password_token", "token") - ctx.SetParamValues(fmt.Sprintf("%d", usr.ID), fmt.Sprintf("%d", pt.ID), token) - _ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM)) - err = tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth)) - assert.Nil(t, err) - ctxPt, ok := ctx.Get(context.PasswordTokenKey).(*ent.PasswordToken) - require.True(t, ok) - assert.Equal(t, pt.ID, ctxPt.ID) -} func TestRequireAdmin(t *testing.T) { ctx, _ := tests.NewContext(c.Web, "/") diff --git a/pkg/middleware/inertia_props.go b/pkg/middleware/inertia_props.go index c712aa9..bc5926d 100644 --- a/pkg/middleware/inertia_props.go +++ b/pkg/middleware/inertia_props.go @@ -2,7 +2,6 @@ package middleware import ( "github.com/labstack/echo/v4" - "github.com/occult/pagode/config" "github.com/occult/pagode/pkg/context" "github.com/occult/pagode/pkg/msg" "github.com/romsar/gonertia/v2" @@ -28,18 +27,12 @@ func InertiaProps() echo.MiddlewareFunc { } } - // Determine auth provider - authProvider := "built-in" - if cfg, ok := ctx.Get(context.ConfigKey).(*config.Config); ok && cfg.Auth.Provider != "" { - authProvider = cfg.Auth.Provider - } - // Set Inertia props newCtx := gonertia.SetProps(ctx.Request().Context(), map[string]any{ "flash": flash, "auth": map[string]any{ "user": user, - "provider": authProvider, + "provider": "casdoor", }, }) diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index 5eb6c33..23859b7 100644 --- a/pkg/routenames/names.go +++ b/pkg/routenames/names.go @@ -15,16 +15,9 @@ const ( About = "about" Contact = "contact" ContactSubmit = "contact.submit" - Login = "login" - LoginSubmit = "login.submit" - Register = "register" - RegisterSubmit = "register.submit" - ForgotPassword = "forgot_password" - ForgotPasswordSubmit = "forgot_password.submit" - Logout = "logout" - VerifyEmail = "verify_email" - ResetPassword = "reset_password" - ResetPasswordSubmit = "reset_password.submit" + Login = "login" + Register = "register" + Logout = "logout" Search = "search" Task = "task" TaskSubmit = "task.submit" @@ -38,7 +31,6 @@ const ( ProfileDestroy = "profile.destroy" ProfileAppearance = "profile.appearance" ProfilePassword = "profile.password" - ProfileUpdatePassword = "profile.update_password" Plans = "plans" PlansSubscribe = "plans.subscribe" Products = "products" diff --git a/pkg/services/auth.go b/pkg/services/auth.go index aded61a..7fe61b8 100644 --- a/pkg/services/auth.go +++ b/pkg/services/auth.go @@ -1,22 +1,12 @@ package services import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" "github.com/occult/pagode/config" "github.com/occult/pagode/ent" - "github.com/occult/pagode/ent/passwordtoken" "github.com/occult/pagode/ent/user" - "github.com/occult/pagode/pkg/context" "github.com/occult/pagode/pkg/session" "github.com/labstack/echo/v4" - "golang.org/x/crypto/bcrypt" ) const ( @@ -38,14 +28,6 @@ func (e NotAuthenticatedError) Error() string { return "user not authenticated" } -// InvalidPasswordTokenError is an error returned when an invalid token is provided -type InvalidPasswordTokenError struct{} - -// Error implements the error interface. -func (e InvalidPasswordTokenError) Error() string { - return "invalid password token" -} - // AuthClient is the client that handles authentication requests type AuthClient struct { config *config.Config @@ -91,7 +73,6 @@ func (c *AuthClient) GetAuthenticatedUserID(ctx echo.Context) (int, error) { if sess.Values[authSessionKeyAuthenticated] == true { userID, ok := sess.Values[authSessionKeyUserID].(int) if !ok { - // Session data is corrupted or has wrong type - treat as not authenticated return 0, NotAuthenticatedError{} } return userID, nil @@ -110,113 +91,3 @@ func (c *AuthClient) GetAuthenticatedUser(ctx echo.Context) (*ent.User, error) { return nil, NotAuthenticatedError{} } - -// CheckPassword check if a given password matches a given hash -func (c *AuthClient) CheckPassword(password, hash string) error { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) -} - -// GeneratePasswordResetToken generates a password reset token for a given user. -// For security purposes, the token itself is not stored in the database but rather -// a hash of the token, exactly how passwords are handled. This method returns both -// the generated token and the token entity which only contains the hash. -func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (string, *ent.PasswordToken, error) { - // Generate the token, which is what will go in the URL, but not the database - token, err := c.RandomToken(c.config.App.PasswordToken.Length) - if err != nil { - return "", nil, err - } - - // Create and save the password reset token - pt, err := c.orm.PasswordToken. - Create(). - SetToken(token). - SetUserID(userID). - Save(ctx.Request().Context()) - - return token, pt, err -} - -// GetValidPasswordToken returns a valid, non-expired password token entity for a given user, token ID and token. -// Since the actual token is not stored in the database for security purposes, if a matching password token entity is -// found a hash of the provided token is compared with the hash stored in the database in order to validate. -func (c *AuthClient) GetValidPasswordToken(ctx echo.Context, userID, tokenID int, token string) (*ent.PasswordToken, error) { - // Ensure expired tokens are never returned - expiration := time.Now().Add(-c.config.App.PasswordToken.Expiration) - - // Query to find a password token entity that matches the given user and token ID - pt, err := c.orm.PasswordToken. - Query(). - Where(passwordtoken.ID(tokenID)). - Where(passwordtoken.HasUserWith(user.ID(userID))). - Where(passwordtoken.CreatedAtGTE(expiration)). - Only(ctx.Request().Context()) - - switch err.(type) { - case *ent.NotFoundError: - case nil: - // Check the token for a hash match - if err := c.CheckPassword(token, pt.Token); err == nil { - return pt, nil - } - default: - if !context.IsCanceledError(err) { - return nil, err - } - } - - return nil, InvalidPasswordTokenError{} -} - -// DeletePasswordTokens deletes all password tokens in the database for a belonging to a given user. -// This should be called after a successful password reset. -func (c *AuthClient) DeletePasswordTokens(ctx echo.Context, userID int) error { - _, err := c.orm.PasswordToken. - Delete(). - Where(passwordtoken.HasUserWith(user.ID(userID))). - Exec(ctx.Request().Context()) - - return err -} - -// RandomToken generates a random token string of a given length -func (c *AuthClient) RandomToken(length int) (string, error) { - b := make([]byte, (length/2)+1) - if _, err := rand.Read(b); err != nil { - return "", err - } - token := hex.EncodeToString(b) - return token[:length], nil -} - -// GenerateEmailVerificationToken generates an email verification token for a given email address using JWT which -// is set to expire based on the duration stored in configuration -func (c *AuthClient) GenerateEmailVerificationToken(email string) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "email": email, - "exp": time.Now().Add(c.config.App.EmailVerificationTokenExpiration).Unix(), - }) - - return token.SignedString([]byte(c.config.App.EncryptionKey)) -} - -// ValidateEmailVerificationToken validates an email verification token and returns the associated email address if -// the token is valid and has not expired -func (c *AuthClient) ValidateEmailVerificationToken(token string) (string, error) { - t, err := jwt.Parse(token, func(t *jwt.Token) (any, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) - } - - return []byte(c.config.App.EncryptionKey), nil - }) - if err != nil { - return "", err - } - - if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid { - return claims["email"].(string), nil - } - - return "", errors.New("invalid or expired token") -} diff --git a/pkg/services/auth_test.go b/pkg/services/auth_test.go index f5dce0b..2b67ed7 100644 --- a/pkg/services/auth_test.go +++ b/pkg/services/auth_test.go @@ -1,14 +1,8 @@ package services import ( - "context" "errors" "testing" - "time" - - "github.com/occult/pagode/ent/passwordtoken" - "github.com/occult/pagode/ent/user" - "golang.org/x/crypto/bcrypt" "github.com/stretchr/testify/require" @@ -41,107 +35,3 @@ func TestAuthClient_Auth(t *testing.T) { assertNoAuth() } - -func TestAuthClient_CheckPassword(t *testing.T) { - pw := "testcheckpassword" - hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) - require.NoError(t, err) - assert.NotEqual(t, hash, pw) - err = c.Auth.CheckPassword(pw, string(hash)) - assert.NoError(t, err) -} - -func TestAuthClient_GeneratePasswordResetToken(t *testing.T) { - token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID) - require.NoError(t, err) - assert.Len(t, token, c.Config.App.PasswordToken.Length) - assert.NoError(t, c.Auth.CheckPassword(token, pt.Token)) -} - -func TestAuthClient_GetValidPasswordToken(t *testing.T) { - // Check that a fake token is not valid - _, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, 1, "faketoken") - assert.Error(t, err) - - // Generate a valid token and check that it is returned - token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID) - require.NoError(t, err) - pt2, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token) - require.NoError(t, err) - assert.Equal(t, pt.ID, pt2.ID) - - // Expire the token by pushing the date far enough back - count, err := c.ORM.PasswordToken. - Update(). - SetCreatedAt(time.Now().Add(-(c.Config.App.PasswordToken.Expiration + time.Hour))). - Where(passwordtoken.ID(pt.ID)). - Save(context.Background()) - require.NoError(t, err) - require.Equal(t, 1, count) - - // Expired tokens should not be valid - _, err = c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token) - assert.Error(t, err) -} - -func TestAuthClient_DeletePasswordTokens(t *testing.T) { - // Create three tokens for the user - for i := 0; i < 3; i++ { - _, _, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID) - require.NoError(t, err) - } - - // Delete all tokens for the user - err := c.Auth.DeletePasswordTokens(ctx, usr.ID) - require.NoError(t, err) - - // Check that no tokens remain - count, err := c.ORM.PasswordToken. - Query(). - Where(passwordtoken.HasUserWith(user.ID(usr.ID))). - Count(context.Background()) - - require.NoError(t, err) - assert.Equal(t, 0, count) -} - -func TestAuthClient_RandomToken(t *testing.T) { - length := c.Config.App.PasswordToken.Length - a, err := c.Auth.RandomToken(length) - require.NoError(t, err) - b, err := c.Auth.RandomToken(length) - require.NoError(t, err) - assert.Len(t, a, length) - assert.Len(t, b, length) - assert.NotEqual(t, a, b) -} - -func TestAuthClient_EmailVerificationToken(t *testing.T) { - t.Run("valid token", func(t *testing.T) { - email := "test@localhost.com" - token, err := c.Auth.GenerateEmailVerificationToken(email) - require.NoError(t, err) - - tokenEmail, err := c.Auth.ValidateEmailVerificationToken(token) - require.NoError(t, err) - assert.Equal(t, email, tokenEmail) - }) - - t.Run("invalid token", func(t *testing.T) { - badToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAbG9jYWxob3N0LmNvbSIsImV4cCI6MTkxNzg2NDAwMH0.ScJCpfEEzlilKfRs_aVouzwPNKI28M3AIm-hyImQHUQ" - _, err := c.Auth.ValidateEmailVerificationToken(badToken) - assert.Error(t, err) - }) - - t.Run("expired token", func(t *testing.T) { - c.Config.App.EmailVerificationTokenExpiration = -time.Hour - email := "test@localhost.com" - token, err := c.Auth.GenerateEmailVerificationToken(email) - require.NoError(t, err) - - _, err = c.Auth.ValidateEmailVerificationToken(token) - assert.Error(t, err) - - c.Config.App.EmailVerificationTokenExpiration = time.Hour * 12 - }) -} diff --git a/pkg/services/casdoor.go b/pkg/services/casdoor.go index d651a28..afb045d 100644 --- a/pkg/services/casdoor.go +++ b/pkg/services/casdoor.go @@ -2,7 +2,9 @@ package services import ( "fmt" + "net/http" "net/url" + "time" "github.com/casdoor/casdoor-go-sdk/casdoorsdk" "github.com/occult/pagode/config" @@ -60,6 +62,17 @@ func (c *CasdoorClient) GetLogoutURL() string { return fmt.Sprintf("%s/api/logout", c.endpoint) } +// IsReachable checks whether the Casdoor endpoint is reachable. +func (c *CasdoorClient) IsReachable() bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(c.endpoint) + if err != nil { + return false + } + resp.Body.Close() + return true +} + // ExchangeCodeAndGetUser exchanges an authorization code for a token and returns the user's email and name. func (c *CasdoorClient) ExchangeCodeAndGetUser(code, state string) (email, name string, err error) { token, err := c.client.GetOAuthToken(code, state) diff --git a/pkg/services/container.go b/pkg/services/container.go index 24a53dd..892e33c 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -73,7 +73,7 @@ type Container struct { // Chat stores the chat room manager. Chat *chat.RoomManager - // Casdoor stores the Casdoor client (nil when using built-in auth). + // Casdoor stores the Casdoor SSO client. Casdoor *CasdoorClient // Inertia for React @@ -237,11 +237,8 @@ func (c *Container) initAuth() { c.Auth = NewAuthClient(c.Config, c.ORM) } -// initCasdoor initializes the Casdoor client if configured. +// initCasdoor initializes the Casdoor SSO client. func (c *Container) initCasdoor() { - if c.Config.Auth.Provider != "casdoor" { - return - } c.Casdoor = NewCasdoorClient(&c.Config.Auth.Casdoor) } diff --git a/pkg/ui/emails/auth.go b/pkg/ui/emails/auth.go index 4b86dde..4d41cb8 100644 --- a/pkg/ui/emails/auth.go +++ b/pkg/ui/emails/auth.go @@ -1,17 +1,11 @@ package emails import ( - "github.com/labstack/echo/v4" - "github.com/occult/pagode/pkg/routenames" - "github.com/occult/pagode/pkg/ui" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) -func ConfirmEmailAddress(ctx echo.Context, username, token string) Node { - url := ui.NewRequest(ctx). - Url(routenames.VerifyEmail, token) - +func ConfirmEmailAddress(username, url string) Node { return Group{ Strong(Textf("Hello %s,", username)), Br(), diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go index bff6f09..27f518c 100644 --- a/pkg/ui/layouts/auth.go +++ b/pkg/ui/layouts/auth.go @@ -58,7 +58,6 @@ func authNavBar(r *ui.Request) Node { Class("navbar-start"), A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")), A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")), - A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")), ), ), ) diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go index bbb7947..f816305 100644 --- a/pkg/ui/layouts/primary.go +++ b/pkg/ui/layouts/primary.go @@ -191,7 +191,6 @@ func sidebarMenu(r *ui.Request) Node { If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)), If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)), If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)), - If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)), ), Iff(r.IsAdmin, adminSubMenu), ) diff --git a/resources/js/Pages/Auth/ConfirmPassword.tsx b/resources/js/Pages/Auth/ConfirmPassword.tsx deleted file mode 100644 index 0233774..0000000 --- a/resources/js/Pages/Auth/ConfirmPassword.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Head, useForm } from "@inertiajs/react"; -import { LoaderCircle } from "lucide-react"; -import { FormEventHandler } from "react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import AuthLayout from "@/Layouts/AuthLayout"; -import InputError from "@/components/InputError"; - -export default function ConfirmPassword() { - const { data, setData, post, processing, errors, reset } = useForm< - Required<{ password: string }> - >({ - password: "", - }); - - const submit: FormEventHandler = (e) => { - e.preventDefault(); - - post("/user/password/confirm", { - onFinish: () => reset("password"), - }); - }; - - return ( - - - -
-
-
- - setData("password", e.target.value)} - /> - - -
- -
- -
-
-
-
- ); -} diff --git a/resources/js/Pages/Auth/ForgotPassword.tsx b/resources/js/Pages/Auth/ForgotPassword.tsx deleted file mode 100644 index 0aa1b6d..0000000 --- a/resources/js/Pages/Auth/ForgotPassword.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Head, useForm } from "@inertiajs/react"; -import { LoaderCircle } from "lucide-react"; -import { FormEventHandler } from "react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import AuthLayout from "@/Layouts/AuthLayout"; -import InputError from "@/components/InputError"; -import TextLink from "@/components/TextLink"; - -export default function ForgotPassword({ status }: { status?: string }) { - const { data, setData, post, processing, errors } = useForm< - Required<{ email: string }> - >({ - email: "", - }); - - const submit: FormEventHandler = (e) => { - e.preventDefault(); - post("/user/password"); - }; - - return ( - - - - {status && ( -
- {status} -
- )} - -
-
-
- - setData("email", e.target.value)} - placeholder="email@example.com" - /> - -
- -
- -
-
- -
- Or, return to - log in -
-
-
- ); -} diff --git a/resources/js/Pages/Auth/Login.tsx b/resources/js/Pages/Auth/Login.tsx deleted file mode 100644 index ac08035..0000000 --- a/resources/js/Pages/Auth/Login.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Head, useForm } from "@inertiajs/react"; -import { LoaderCircle } from "lucide-react"; -import { FormEventHandler } from "react"; - -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import AuthLayout from "@/Layouts/AuthLayout"; -import TextLink from "@/components/TextLink"; -import InputError from "@/components/InputError"; - -type LoginForm = { - email: string; - password: string; - remember: boolean; -}; - -interface LoginProps { - status?: string; - canResetPassword: boolean; -} - -export default function Login({ status, canResetPassword }: LoginProps) { - const { data, setData, post, processing, errors, reset } = useForm< - Required - >({ - email: "", - password: "", - remember: false, - }); - - const submit: FormEventHandler = (e) => { - e.preventDefault(); - post("/user/login", { - onFinish: () => reset("password"), - }); - }; - - return ( - - - -
-
-
- - setData("email", e.target.value)} - placeholder="email@example.com" - /> - -
- -
-
- - {canResetPassword && ( - - Forgot password? - - )} -
- setData("password", e.target.value)} - placeholder="Password" - /> - -
- -
- setData("remember", !data.remember)} - tabIndex={3} - /> - -
- - -
- -
- Don't have an account?{" "} - - Sign up - -
-
- - {status && ( -
- {status} -
- )} -
- ); -} diff --git a/resources/js/Pages/Auth/Register.tsx b/resources/js/Pages/Auth/Register.tsx deleted file mode 100644 index f43a0ee..0000000 --- a/resources/js/Pages/Auth/Register.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Head, useForm } from "@inertiajs/react"; -import { LoaderCircle } from "lucide-react"; -import { FormEventHandler } from "react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import InputError from "@/components/InputError"; -import TextLink from "@/components/TextLink"; -import AuthLayout from "@/Layouts/AuthLayout"; - -type RegisterForm = { - name: string; - email: string; - password: string; - password_confirmation: string; -}; - -export default function Register() { - const { data, setData, post, processing, errors } = useForm< - Required - >({ - name: "", - email: "", - password: "", - password_confirmation: "", - }); - - const submit: FormEventHandler = (e) => { - e.preventDefault(); - post("/user/register", { - forceFormData: true, - }); - }; - - return ( - - - -
-
-
- - setData("name", e.target.value)} - placeholder="Your name" - /> - -
- -
- - setData("email", e.target.value)} - placeholder="email@example.com" - /> - -
- -
- - setData("password", e.target.value)} - placeholder="Password" - /> - -
- -
- - setData("password_confirmation", e.target.value)} - placeholder="Confirm your password" - /> - -
- - -
- -
- Already have an account?{" "} - - Log in - -
-
-
- ); -} diff --git a/resources/js/Pages/Auth/ResetPassword.tsx b/resources/js/Pages/Auth/ResetPassword.tsx deleted file mode 100644 index b61bd0b..0000000 --- a/resources/js/Pages/Auth/ResetPassword.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Head, useForm } from "@inertiajs/react"; -import { LoaderCircle } from "lucide-react"; -import { FormEventHandler } from "react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import AuthLayout from "@/Layouts/AuthLayout"; -import InputError from "@/components/InputError"; - -interface ResetPasswordProps { - token: string; - email: string; - userID: string; - passwordTokenID: string; -} - -type ResetPasswordForm = { - token: string; - email: string; - password: string; - password_confirmation: string; -}; - -export default function ResetPassword({ - token, - email, - userID, - passwordTokenID, -}: ResetPasswordProps) { - const { data, setData, post, processing, errors, reset } = useForm< - Required - >({ - token: token, - email: email, - password: "", - password_confirmation: "", - }); - - const submit: FormEventHandler = (e) => { - e.preventDefault(); - post(`/user/password/reset/token/${userID}/${passwordTokenID}/${token}`, { - forceFormData: true, - onSuccess: () => reset("password", "password_confirmation"), - }); - }; - - return ( - - - -
-
-
- - setData("email", e.target.value)} - /> - -
- -
- - setData("password", e.target.value)} - placeholder="Password" - /> - -
- -
- - setData("password_confirmation", e.target.value)} - placeholder="Confirm password" - /> - -
- - -
-
-
- ); -} diff --git a/resources/js/Pages/Auth/VerifyEmail.tsx b/resources/js/Pages/Auth/VerifyEmail.tsx deleted file mode 100644 index 4166439..0000000 --- a/resources/js/Pages/Auth/VerifyEmail.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Head, useForm } from "@inertiajs/react"; -import { LoaderCircle } from "lucide-react"; -import { FormEventHandler } from "react"; - -import { Button } from "@/components/ui/button"; -import AuthLayout from "@/Layouts/AuthLayout"; -import TextLink from "@/components/TextLink"; - -export default function VerifyEmail({ status }: { status?: string }) { - const { post, processing } = useForm({}); - - const submit: FormEventHandler = (e) => { - e.preventDefault(); - post("/user/email/verify"); - }; - - return ( - - - - {status === "verification-link-sent" && ( -
- A new verification link has been sent to the email address you - provided during registration. -
- )} - -
- - - - Log out - -
-
- ); -} diff --git a/resources/js/Pages/Settings/Password.tsx b/resources/js/Pages/Settings/Password.tsx index 745d363..5ac1265 100644 --- a/resources/js/Pages/Settings/Password.tsx +++ b/resources/js/Pages/Settings/Password.tsx @@ -1,16 +1,9 @@ import { type BreadcrumbItem } from "@/types"; -import { SharedProps } from "@/types/global"; -import { Transition } from "@headlessui/react"; -import { Head, useForm, usePage } from "@inertiajs/react"; -import { FormEventHandler, useRef } from "react"; +import { Head } from "@inertiajs/react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import AppLayout from "@/Layouts/AppLayout"; import SettingsLayout from "@/Layouts/Settings/Layout"; import HeadingSmall from "@/components/HeadingSmall"; -import InputError from "@/components/InputError"; const breadcrumbs: BreadcrumbItem[] = [ { @@ -20,135 +13,18 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function Password() { - const { auth } = usePage().props; - const passwordInput = useRef(null); - const currentPasswordInput = useRef(null); - - const { data, setData, errors, post, reset, processing, recentlySuccessful } = - useForm({ - current_password: "", - password: "", - password_confirmation: "", - }); - - const updatePassword: FormEventHandler = (e) => { - e.preventDefault(); - - post("/profile/update-password", { - forceFormData: true, - preserveScroll: true, - onSuccess: () => reset(), - onError: (errors) => { - if (errors.password) { - reset("password", "password_confirmation"); - passwordInput.current?.focus(); - } - - if (errors.current_password) { - reset("current_password"); - currentPasswordInput.current?.focus(); - } - }, - }); - }; - - if (auth.provider === "casdoor") { - return ( - - - -
- -

- To change your password, please visit your Casdoor account settings. -

-
-
-
- ); - } - return ( -
- -
-
- - - setData("current_password", e.target.value)} - type="password" - className="mt-1 block w-full" - autoComplete="current-password" - placeholder="Current password" - /> - - -
- -
- - - setData("password", e.target.value)} - type="password" - className="mt-1 block w-full" - autoComplete="new-password" - placeholder="New password" - /> - - -
- -
- - - - setData("password_confirmation", e.target.value) - } - type="password" - className="mt-1 block w-full" - autoComplete="new-password" - placeholder="Confirm password" - /> - - -
- -
- - - -

Saved

-
-
-
+

+ To change your password, please visit your Casdoor account settings. +

diff --git a/resources/js/components/DeleteUser.tsx b/resources/js/components/DeleteUser.tsx index 44b4a7f..9bc2596 100644 --- a/resources/js/components/DeleteUser.tsx +++ b/resources/js/components/DeleteUser.tsx @@ -1,9 +1,7 @@ import { useForm } from "@inertiajs/react"; -import { FormEventHandler, useRef } from "react"; +import { FormEventHandler } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Dialog, @@ -14,32 +12,14 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import InputError from "./InputError"; import HeadingSmall from "./HeadingSmall"; -type DeleteUserForm = { - password: string; -}; - export default function DeleteUser() { - const passwordInput = useRef(null); - const { data, setData, post, processing, reset, errors, clearErrors } = - useForm>({ password: "" }); + const { post, processing } = useForm({}); const deleteUser: FormEventHandler = (e) => { e.preventDefault(); - - post("/profile/delete", { - preserveScroll: true, - onSuccess: () => closeModal(), - onError: () => passwordInput.current?.focus(), - onFinish: () => reset(), - }); - }; - - const closeModal = () => { - clearErrors(); - reset(); + post("/profile/delete", { preserveScroll: true }); }; return ( @@ -66,34 +46,12 @@ export default function DeleteUser() { Once your account is deleted, all of its resources and data will - also be permanently deleted. Please enter your password to confirm - you would like to permanently delete your account. + also be permanently deleted. -
-
- - - setData("password", e.target.value)} - placeholder="Password" - autoComplete="current-password" - /> - - -
- + - +