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/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 2ef3b35..820a5c4 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,21 @@ type ( Currency string } + // AuthConfig stores the authentication configuration (Casdoor SSO). + AuthConfig struct { + 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 +177,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 +193,14 @@ func GetConfig() (Config, error) { viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + // Explicitly bind Casdoor env vars so Viper picks them up. + 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..d1f00c1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -62,6 +62,15 @@ payment: webhookSecret: "whsec_your_webhook_secret_here" currency: "usd" +auth: + 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..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,147 +18,62 @@ import ( type Auth struct { config *config.Config auth *services.AuthClient - mail *services.MailClient - orm *ent.Client + casdoor *services.CasdoorClient Inertia *inertia.Inertia } -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 -} - func init() { Register(new(Auth)) } 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.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 { - canResetPassword := true - - 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 !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() } - return nil -} - -func (h *Auth) LoginSubmit(ctx echo.Context) error { - w := ctx.Response().Writer - r := ctx.Request() + callbackURL := h.config.App.Host + ctx.Echo().Reverse(routenames.CasdoorCallback) + signinURL := h.casdoor.GetSigninURL(callbackURL, "pagode") - 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 + 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) +} - 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 { @@ -176,366 +82,16 @@ 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 { - 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) + // 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) } } - 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/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/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 f4f38f0..1f4cd22 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -36,7 +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 - cookieStore.Options.SameSite = http.SameSiteStrictMode + cookieStore.Options.SameSite = http.SameSiteLaxMode g.Use( echomw.RemoveTrailingSlashWithConfig(echomw.TrailingSlashConfig{ @@ -59,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: http.SameSiteStrictMode, + 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 5c1eb6b..bc5926d 100644 --- a/pkg/middleware/inertia_props.go +++ b/pkg/middleware/inertia_props.go @@ -31,7 +31,8 @@ func InertiaProps() echo.MiddlewareFunc { newCtx := gonertia.SetProps(ctx.Request().Context(), map[string]any{ "flash": flash, "auth": map[string]any{ - "user": user, + "user": user, + "provider": "casdoor", }, }) diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index c617252..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" @@ -53,6 +45,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/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 new file mode 100644 index 0000000..afb045d --- /dev/null +++ b/pkg/services/casdoor.go @@ -0,0 +1,101 @@ +package services + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "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) +} + +// 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) + 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..892e33c 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 SSO client. + 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,11 @@ func (c *Container) initAuth() { c.Auth = NewAuthClient(c.Config, c.ORM) } +// initCasdoor initializes the Casdoor SSO client. +func (c *Container) initCasdoor() { + c.Casdoor = NewCasdoorClient(&c.Config.Auth.Casdoor) +} + // initMail initialize the mail client. func (c *Container) initMail() { var err error 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 95e395e..5ac1265 100644 --- a/resources/js/Pages/Settings/Password.tsx +++ b/resources/js/Pages/Settings/Password.tsx @@ -1,15 +1,9 @@ import { type BreadcrumbItem } from "@/types"; -import { Transition } from "@headlessui/react"; -import { Head, useForm } 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[] = [ { @@ -19,115 +13,18 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function Password() { - 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(); - } - }, - }); - }; - 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" - /> - - -
- + - +