diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2025-08-15 19:20:48 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2025-08-15 19:20:48 +0100 |
| commit | 8f7dec8ba6b2f9bde01afd0a110596ebbd43e0ed (patch) | |
| tree | 7b4f203d92f4b99b1e98fac314415e293984196b | |
| parent | 4697556cac819c47d068819b9fc9c3b4ea84e279 (diff) | |
Implement OIDC
30 files changed, 971 insertions, 312 deletions
diff --git a/api/dto/auth.go b/api/dto/auth.go new file mode 100644 index 0000000..0379f21 --- /dev/null +++ b/api/dto/auth.go @@ -0,0 +1,30 @@ +package dto + +type LoginBasicRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type LoginOAuthCallbackRequest struct { + Code string `json:"code" validate:"required"` + State string `json:"state" validate:"required"` +} + +type LoginOAuthOutboundResponse struct { + URL string `json:"url" validate:"required"` +} + +type LoginResponse struct { + ID int32 `json:"id"` + Username string `json:"username"` +} + +type LoginOptionsResponse struct { + Options []LoginOption `json:"options"` +} + +type LoginOption struct { + Name string `json:"name"` + Identifier string `json:"identifier"` + Type string `json:"type"` +} diff --git a/api/dto/users.go b/api/dto/users.go index 685fa07..5fb269f 100644 --- a/api/dto/users.go +++ b/api/dto/users.go @@ -8,13 +8,3 @@ type RegisterRequest struct { type RegisterResponse struct { ID int32 `json:"id"` } - -type LoginRequest struct { - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` -} - -type LoginResponse struct { - ID int32 `json:"id"` - Username string `json:"username"` -} diff --git a/api/handlers/auth.go b/api/handlers/auth.go new file mode 100644 index 0000000..c19fc3a --- /dev/null +++ b/api/handlers/auth.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/auth" + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/LMBishop/confplanner/pkg/session" +) + +func Login(authService auth.Service, store session.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + provider := authService.GetAuthProvider(r.PathValue("provider")) + + var user *sqlc.User + var err error + switch p := provider.(type) { + case *auth.BasicAuthProvider: + user, err = doBasicAuth(r, p) + case *auth.OIDCAuthProvider: + user, err = doOIDCAuthJourney(r, p) + default: + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Unknown auth provider", + } + } + + if err != nil { + return err + } + + // TODO X-Forwarded-For + session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent()) + if err != nil { + return err + } + + cookie := &http.Cookie{ + Name: "confplanner_session", + Value: session.Token, + Path: "/api", + } + http.SetCookie(w, cookie) + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: &dto.LoginResponse{ + ID: user.ID, + Username: user.Username, + }, + } + }) +} + +func GetLoginOptions(authService auth.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var loginOptions []dto.LoginOption + + for _, identifier := range authService.GetAuthProviders() { + provider := authService.GetAuthProvider(identifier) + loginOptions = append(loginOptions, dto.LoginOption{ + Name: provider.Name(), + Identifier: identifier, + Type: provider.Type(), + }) + } + return &dto.OkResponse{ + Code: http.StatusOK, + Data: &dto.LoginOptionsResponse{ + Options: loginOptions, + }, + } + }) +} + +func doBasicAuth(r *http.Request, p *auth.BasicAuthProvider) (*sqlc.User, error) { + var request dto.LoginBasicRequest + if err := dto.ReadDto(r, &request); err != nil { + return nil, err + } + + user, err := p.Authenticate(request.Username, request.Password) + if err != nil { + return nil, err + } + + if user == nil { + return nil, &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Username and password combination not found", + } + } + + return user, nil +} + +func doOIDCAuthJourney(r *http.Request, p *auth.OIDCAuthProvider) (*sqlc.User, error) { + var request dto.LoginOAuthCallbackRequest + if err := dto.ReadDto(r, &request); err != nil { + url, err := p.StartJourney(r.RemoteAddr, r.UserAgent()) + if err != nil { + return nil, &dto.ErrorResponse{ + Code: http.StatusInternalServerError, + Message: "Could not start OAuth journey", + } + } + + return nil, &dto.OkResponse{ + Code: http.StatusTemporaryRedirect, + Data: &dto.LoginOAuthOutboundResponse{ + URL: url, + }, + } + } + + user, err := p.CompleteJourney(r.Context(), request.Code, request.State, r.RemoteAddr, r.UserAgent()) + if err != nil { + if errors.Is(err, auth.ErrNotAuthorised) { + return nil, &dto.ErrorResponse{ + Code: http.StatusForbidden, + Message: "You are not authorised to use this service", + } + } else if errors.Is(err, auth.ErrInvalidState) { + return nil, &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Invalid state", + } + } else if errors.Is(err, auth.ErrStateVerificationFailed) { + return nil, &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "State verification failed", + } + } else if errors.Is(err, auth.ErrUserSyncFailed) { + return nil, &dto.ErrorResponse{ + Code: http.StatusInternalServerError, + Message: "User sync failed", + } + } + slog.Error("error completing oidc journey", "error", err, "ip", r.RemoteAddr) + return nil, err + } + + return user, nil +} diff --git a/api/handlers/calendar.go b/api/handlers/calendar.go index 5b14972..e0ed27f 100644 --- a/api/handlers/calendar.go +++ b/api/handlers/calendar.go @@ -32,7 +32,7 @@ func GetCalendar(calendarService calendar.Service, baseURL string) http.HandlerF ID: cal.ID, Name: cal.Name, Key: cal.Key, - URL: baseURL + "/calendar/ical?name=" + cal.Name + "&key=" + cal.Key, + URL: baseURL + "/api/calendar/ical?name=" + cal.Name + "&key=" + cal.Key, }, } }) diff --git a/api/handlers/users.go b/api/handlers/users.go index efb2e29..3a1788d 100644 --- a/api/handlers/users.go +++ b/api/handlers/users.go @@ -5,18 +5,27 @@ import ( "net/http" "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/auth" "github.com/LMBishop/confplanner/pkg/session" "github.com/LMBishop/confplanner/pkg/user" ) -func Register(service user.Service) http.HandlerFunc { +func Register(userService user.Service, authService auth.Service) http.HandlerFunc { return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { var request dto.RegisterRequest if err := dto.ReadDto(r, &request); err != nil { return err } - createdUser, err := service.CreateUser(request.Username, request.Password) + basicAuthProvider := authService.GetAuthProvider("basic") + if _, ok := basicAuthProvider.(*auth.BasicAuthProvider); !ok { + return &dto.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Registrations are only accepted via an identity provider", + } + } + + createdUser, err := userService.CreateUser(request.Username, request.Password) if err != nil { if errors.Is(err, user.ErrUserExists) { return &dto.ErrorResponse{ @@ -42,46 +51,6 @@ func Register(service user.Service) http.HandlerFunc { }) } -func Login(service user.Service, store session.Service) http.HandlerFunc { - return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { - var request dto.LoginRequest - if err := dto.ReadDto(r, &request); err != nil { - return err - } - - user, err := service.Authenticate(request.Username, request.Password) - if err != nil { - return err - } - - if user == nil { - return &dto.ErrorResponse{ - Code: http.StatusBadRequest, - Message: "Username and password combination not found", - } - } - - session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent()) - if err != nil { - return err - } - - cookie := &http.Cookie{ - Name: "confplanner_session", - Value: session.Token, - } - http.SetCookie(w, cookie) - - return &dto.OkResponse{ - Code: http.StatusOK, - Data: &dto.LoginResponse{ - ID: user.ID, - Username: user.Username, - }, - } - }) -} - func Logout(store session.Service) http.HandlerFunc { return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { session := r.Context().Value("session").(*session.UserSession) diff --git a/api/router.go b/api/router.go index 423529f..38e67e6 100644 --- a/api/router.go +++ b/api/router.go @@ -5,6 +5,7 @@ import ( "github.com/LMBishop/confplanner/api/handlers" "github.com/LMBishop/confplanner/api/middleware" + "github.com/LMBishop/confplanner/pkg/auth" "github.com/LMBishop/confplanner/pkg/calendar" "github.com/LMBishop/confplanner/pkg/favourites" "github.com/LMBishop/confplanner/pkg/ical" @@ -20,6 +21,7 @@ type ApiServices struct { CalendarService calendar.Service IcalService ical.Service SessionService session.Service + AuthService auth.Service } func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux { @@ -27,9 +29,10 @@ func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux { mux := http.NewServeMux() - mux.HandleFunc("POST /register", handlers.Register(apiServices.UserService)) - mux.HandleFunc("POST /login", handlers.Login(apiServices.UserService, apiServices.SessionService)) - mux.HandleFunc("POST /logout", mustAuthenticate(handlers.Register(apiServices.UserService))) + mux.HandleFunc("POST /register", handlers.Register(apiServices.UserService, apiServices.AuthService)) + mux.HandleFunc("GET /login", handlers.GetLoginOptions(apiServices.AuthService)) + mux.HandleFunc("POST /login/{provider}", handlers.Login(apiServices.AuthService, apiServices.SessionService)) + mux.HandleFunc("POST /logout", mustAuthenticate(handlers.Logout(apiServices.SessionService))) mux.HandleFunc("GET /favourites", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService))) mux.HandleFunc("POST /favourites", mustAuthenticate(handlers.CreateFavourite(apiServices.FavouritesService))) @@ -8,14 +8,16 @@ require ( github.com/jackc/pgx/v5 v5.7.2 github.com/microcosm-cc/bluemonday v1.0.27 github.com/pressly/goose/v3 v3.24.1 - golang.org/x/crypto v0.32.0 + golang.org/x/crypto v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/coreos/go-oidc/v3 v3.15.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/uuid v1.6.0 // indirect @@ -34,12 +36,16 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/stretchr/testify v1.10.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect ) @@ -1,9 +1,13 @@ github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= +github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -47,6 +51,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= @@ -55,16 +66,28 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/config.go b/internal/config/config.go index 564adbe..1acda1e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,10 +17,25 @@ type Config struct { Conference struct { ScheduleURL string `yaml:"scheduleURL"` } `yaml:"conference"` + Auth struct { + EnableBasicAuth bool `yaml:"enableBasicAuth"` + AuthProviders []AuthProvider `yaml:"authProviders"` + } AcceptRegistrations bool `yaml:"acceptRegistrations"` BaseURL string `yaml:"baseURL"` } +type AuthProvider struct { + Identifier string `yaml:"identifier"` + Name string `yaml:"name"` + ClientID string `yaml:"clientID"` + ClientSecret string `yaml:"clientSecret"` + Endpoint string `yaml:"endpoint"` + LoginFilter string `yaml:"loginFilter"` + LoginFilterAllowedValues []string `yaml:"loginFilterAllowedValues"` + UserSyncFilter string `yaml:"userSyncFilter"` +} + func ReadConfig(configPath string, dst *Config) error { config, err := os.ReadFile(configPath) if err != nil { @@ -8,6 +8,7 @@ import ( "github.com/LMBishop/confplanner/api" "github.com/LMBishop/confplanner/internal/config" + "github.com/LMBishop/confplanner/pkg/auth" "github.com/LMBishop/confplanner/pkg/calendar" "github.com/LMBishop/confplanner/pkg/database" "github.com/LMBishop/confplanner/pkg/favourites" @@ -50,6 +51,32 @@ func run() error { calendarService := calendar.NewService(pool) icalService := ical.NewService(favouritesService, scheduleService) sessionService := session.NewMemoryStore() + authService := auth.NewService() + + if c.Auth.EnableBasicAuth { + authService.RegisterAuthProvider("basic", auth.NewBasicAuthProvider(userService)) + } + for _, authProvider := range c.Auth.AuthProviders { + provider, err := auth.NewOIDCAuthProvider( + userService, + authProvider.Name, + authProvider.ClientID, + authProvider.ClientSecret, + authProvider.Endpoint, + fmt.Sprintf("%s/login/%s", c.BaseURL, authProvider.Identifier), + authProvider.LoginFilter, + authProvider.UserSyncFilter, + authProvider.LoginFilterAllowedValues, + ) + if err != nil { + return fmt.Errorf("failed to create OIDC auth provider: %w", err) + } + + err = authService.RegisterAuthProvider(authProvider.Identifier, provider) + if err != nil { + return fmt.Errorf("failed to register OIDC auth provider: %w", err) + } + } mux := http.NewServeMux() api := api.NewServer(api.ApiServices{ @@ -59,6 +86,7 @@ func run() error { CalendarService: calendarService, IcalService: icalService, SessionService: sessionService, + AuthService: authService, }, c.BaseURL) web := web.NewWebFileServer() diff --git a/pkg/auth/basic.go b/pkg/auth/basic.go new file mode 100644 index 0000000..dafd93f --- /dev/null +++ b/pkg/auth/basic.go @@ -0,0 +1,56 @@ +package auth + +import ( + "errors" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/LMBishop/confplanner/pkg/user" + "golang.org/x/crypto/bcrypt" +) + +type BasicAuthProvider struct { + userService user.Service +} + +func NewBasicAuthProvider(userService user.Service) AuthProvider { + return &BasicAuthProvider{ + userService: userService, + } +} + +func (p *BasicAuthProvider) Authenticate(username string, password string) (*sqlc.User, error) { + random, err := bcrypt.GenerateFromPassword([]byte("00000000"), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + u, err := p.userService.GetUserByName(username) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + bcrypt.CompareHashAndPassword(random, []byte(password)) + return nil, nil + } + return nil, err + } + if !u.Password.Valid { + bcrypt.CompareHashAndPassword(random, []byte(password)) + return nil, nil + } + + if err = bcrypt.CompareHashAndPassword([]byte(u.Password.String), []byte(password)); err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return nil, nil + } + return nil, err + } + + return u, nil +} + +func (p *BasicAuthProvider) Name() string { + return "Basic" +} + +func (p *BasicAuthProvider) Type() string { + return "basic" +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go new file mode 100644 index 0000000..9c45e7a --- /dev/null +++ b/pkg/auth/oauth.go @@ -0,0 +1,191 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/tidwall/gjson" + "golang.org/x/oauth2" +) + +type OIDCAuthProvider struct { + name string + userService user.Service + oauthConfig *oauth2.Config + oidcProvider *oidc.Provider + oidcVerifier *oidc.IDTokenVerifier + loginFilter string + loginFilterAllowedValues []string + userSyncFilter string + states map[string]*oidcState + lock sync.RWMutex +} + +type oidcState struct { + expiry time.Time + ip string + userAgent string +} + +var ( + ErrStateVerificationFailed = errors.New("state verification failed") + ErrInvalidState = errors.New("invalid state") + ErrMissingIDToken = errors.New("missing ID token") + ErrNotAuthorised = errors.New("not authorised") + ErrUserSyncFailed = errors.New("user sync failed") +) + +func NewOIDCAuthProvider(userService user.Service, name, clientID, clientSecret, endpoint, callbackURL, loginFilter, userSyncFilter string, loginFilterAllowedValues []string) (AuthProvider, error) { + provider, err := oidc.NewProvider(context.Background(), endpoint) + if err != nil { + return nil, err + } + + return &OIDCAuthProvider{ + name: name, + userService: userService, + oauthConfig: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: callbackURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + oidcProvider: provider, + oidcVerifier: provider.Verifier(&oidc.Config{ClientID: clientID}), + loginFilter: loginFilter, + loginFilterAllowedValues: loginFilterAllowedValues, + userSyncFilter: userSyncFilter, + states: make(map[string]*oidcState), + }, nil +} + +func (p *OIDCAuthProvider) StartJourney(ip string, userAgent string) (string, error) { + b := make([]byte, 50) + if _, err := rand.Read(b); err != nil { + return "", err + } + + state := base64.URLEncoding.EncodeToString(b) + + p.lock.Lock() + defer p.lock.Unlock() + + p.states[state] = &oidcState{ + expiry: time.Now().Add(time.Minute * 5), + ip: ip, + userAgent: userAgent, + } + + return p.oauthConfig.AuthCodeURL(state), nil +} + +func (p *OIDCAuthProvider) CompleteJourney(ctx context.Context, authCode string, state string, ip string, userAgent string) (*sqlc.User, error) { + var s *oidcState + + p.lock.Lock() + s = p.states[state] + delete(p.states, state) + p.lock.Unlock() + + if s == nil { + return nil, ErrInvalidState + } + + //if time.Now().After(s.expiry) || s.ip != ip || s.userAgent != userAgent { + // return nil, ErrStateVerificationFailed + //} + if time.Now().After(s.expiry) || s.userAgent != userAgent { + return nil, ErrStateVerificationFailed + } + + oauth2Token, err := p.oauthConfig.Exchange(ctx, authCode) + if err != nil { + return nil, err + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return nil, ErrMissingIDToken + } + + _, err = p.oidcVerifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, err + } + + claims, err := getRawClaims(rawIDToken) + if err != nil { + return nil, err + } + + if p.loginFilter != "" { + rolesClaim := gjson.Get(claims, p.loginFilter) + if !rolesClaim.Exists() { + return nil, fmt.Errorf("cannot verify authorisation as '%s' is missing from claims", p.loginFilter) + } + roles := rolesClaim.Array() + var authorisation bool + out: + for _, allowedRole := range p.loginFilterAllowedValues { + for _, role := range roles { + if role.Str == allowedRole { + authorisation = true + break out + } + } + } + if !authorisation { + return nil, ErrNotAuthorised + } + } + + usernameClaim := gjson.Get(claims, p.userSyncFilter) + if !usernameClaim.Exists() { + return nil, fmt.Errorf("cannot sync user as '%s' is missing from claims", p.userSyncFilter) + } + username := usernameClaim.Str + + u, err := p.userService.GetUserByName(username) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + u, err = p.userService.CreateUser(username, "") + if err != nil { + return nil, errors.Join(ErrUserSyncFailed, err) + } + } else { + return nil, errors.Join(ErrUserSyncFailed, err) + } + } + + return u, nil +} + +func (p *OIDCAuthProvider) Name() string { + return p.name +} + +func (p *OIDCAuthProvider) Type() string { + return "oidc" +} + +func getRawClaims(p string) (string, error) { + parts := strings.Split(p, ".") + if len(parts) < 2 { + return "", fmt.Errorf("malformed jwt, expected 3 parts got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("malformed jwt payload: %w", err) + } + return string(payload[:]), nil +} diff --git a/pkg/auth/service.go b/pkg/auth/service.go new file mode 100644 index 0000000..be1d6e7 --- /dev/null +++ b/pkg/auth/service.go @@ -0,0 +1,49 @@ +package auth + +import ( + "fmt" + "sync" +) + +type Service interface { + GetAuthProvider(string) AuthProvider + GetAuthProviders() []string + RegisterAuthProvider(string, AuthProvider) error +} + +type AuthProvider interface { + Name() string + Type() string +} + +type service struct { + authProviders map[string]AuthProvider + order []string + lock sync.Mutex +} + +func NewService() Service { + return &service{ + authProviders: make(map[string]AuthProvider), + } +} + +func (s *service) GetAuthProvider(name string) AuthProvider { + return s.authProviders[name] +} + +func (s *service) GetAuthProviders() []string { + return s.order +} + +func (s *service) RegisterAuthProvider(name string, provider AuthProvider) error { + s.lock.Lock() + defer s.lock.Unlock() + + if _, ok := s.authProviders[name]; ok { + return fmt.Errorf("duplicate auth provider: %s", name) + } + s.order = append(s.order, name) + s.authProviders[name] = provider + return nil +} diff --git a/pkg/database/migrations/0002_nullable_passwords.sql b/pkg/database/migrations/0002_nullable_passwords.sql new file mode 100644 index 0000000..2f31366 --- /dev/null +++ b/pkg/database/migrations/0002_nullable_passwords.sql @@ -0,0 +1,4 @@ +-- +goose Up +ALTER TABLE users DROP CONSTRAINT valid_hash; +ALTER TABLE users ALTER COLUMN password DROP NOT NULL; +ALTER TABLE users ADD CONSTRAINT valid_hash CHECK (length(password) = 60 OR password IS NULL); diff --git a/pkg/database/sqlc/calendars.sql.go b/pkg/database/sqlc/calendars.sql.go index 47ae37f..ad55a51 100644 --- a/pkg/database/sqlc/calendars.sql.go +++ b/pkg/database/sqlc/calendars.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: calendars.sql package sqlc diff --git a/pkg/database/sqlc/db.go b/pkg/database/sqlc/db.go index b931bc5..2725108 100644 --- a/pkg/database/sqlc/db.go +++ b/pkg/database/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package sqlc diff --git a/pkg/database/sqlc/favourites.sql.go b/pkg/database/sqlc/favourites.sql.go index 359ae9d..b13261f 100644 --- a/pkg/database/sqlc/favourites.sql.go +++ b/pkg/database/sqlc/favourites.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: favourites.sql package sqlc diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go index e38851a..57fd082 100644 --- a/pkg/database/sqlc/models.go +++ b/pkg/database/sqlc/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package sqlc @@ -23,7 +23,7 @@ type Favourite struct { } type User struct { - ID int32 `json:"id"` - Username string `json:"username"` - Password string `json:"password"` + ID int32 `json:"id"` + Username string `json:"username"` + Password pgtype.Text `json:"password"` } diff --git a/pkg/database/sqlc/users.sql.go b/pkg/database/sqlc/users.sql.go index dfd2c2f..cf0aeb9 100644 --- a/pkg/database/sqlc/users.sql.go +++ b/pkg/database/sqlc/users.sql.go @@ -1,12 +1,14 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: users.sql package sqlc import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const createUser = `-- name: CreateUser :one @@ -19,8 +21,8 @@ RETURNING id, username, password ` type CreateUserParams struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password pgtype.Text `json:"password"` } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { diff --git a/pkg/session/memory.go b/pkg/session/memory.go index 96e416b..f02b792 100644 --- a/pkg/session/memory.go +++ b/pkg/session/memory.go @@ -2,7 +2,7 @@ package session import ( "crypto/rand" - "encoding/hex" + "encoding/base64" "fmt" "sync" "time" @@ -91,5 +91,5 @@ func generateSessionToken() string { if _, err := rand.Read(b); err != nil { return "" } - return hex.EncodeToString(b) + return base64.StdEncoding.EncodeToString(b) } diff --git a/pkg/user/service.go b/pkg/user/service.go index 7784811..21cfa9e 100644 --- a/pkg/user/service.go +++ b/pkg/user/service.go @@ -9,6 +9,7 @@ import ( "github.com/LMBishop/confplanner/pkg/database/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) @@ -17,7 +18,6 @@ type Service interface { CreateUser(username string, password string) (*sqlc.User, error) GetUserByName(username string) (*sqlc.User, error) GetUserByID(id int32) (*sqlc.User, error) - Authenticate(username string, password string) (*sqlc.User, error) } var ( @@ -43,18 +43,30 @@ func (s *service) CreateUser(username string, password string) (*sqlc.User, erro return nil, ErrNotAcceptingRegistrations } + var passwordHash pgtype.Text queries := sqlc.New(s.pool) - var passwordBytes = []byte(password) + if password != "" { + var passwordBytes = []byte(password) - hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("could not hash password: %w", err) + hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("could not hash password: %w", err) + } + + passwordHash = pgtype.Text{ + String: string(hash), + Valid: true, + } + } else { + passwordHash = pgtype.Text{ + Valid: false, + } } user, err := queries.CreateUser(context.Background(), sqlc.CreateUserParams{ Username: strings.ToLower(username), - Password: string(hash), + Password: passwordHash, }) if err != nil { var pgErr *pgconn.PgError @@ -94,28 +106,3 @@ func (s *service) GetUserByID(id int32) (*sqlc.User, error) { return &user, nil } - -func (s *service) Authenticate(username string, password string) (*sqlc.User, error) { - random, err := bcrypt.GenerateFromPassword([]byte("00000000"), bcrypt.DefaultCost) - if err != nil { - return nil, err - } - - user, err := s.GetUserByName(username) - if err != nil { - if errors.Is(err, ErrUserNotFound) { - bcrypt.CompareHashAndPassword(random, []byte(password)) - return nil, nil - } - return nil, err - } - - if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { - if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { - return nil, nil - } - return nil, err - } - - return user, nil -} diff --git a/web/composables/fetch-favourites.ts b/web/composables/fetch-favourites.ts index 97b443a..e586d5b 100644 --- a/web/composables/fetch-favourites.ts +++ b/web/composables/fetch-favourites.ts @@ -1,4 +1,4 @@ -export default function useFetchFavourites() { +export default function() { const favouritesStore = useFavouritesStore(); const errorStore = useErrorStore(); const config = useRuntimeConfig(); diff --git a/web/composables/fetch-login.ts b/web/composables/fetch-login.ts new file mode 100644 index 0000000..707a04f --- /dev/null +++ b/web/composables/fetch-login.ts @@ -0,0 +1,25 @@ +import { useLoginOptionsStore } from "~/stores/login-options"; + +export default function() { + const loginOptionsStore = useLoginOptionsStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); + + loginOptionsStore.setStatus('pending') + + $fetch(config.public.baseURL + '/login', { + method: 'GET', + server: false, + lazy: true, + onResponse: ({ response }) => { + if (!response.ok) { + errorStore.setError(response._data.message || 'An unknown error occurred'); + } + + if (response._data) { + loginOptionsStore.setLoginOptions((response._data as any).data.options); + loginOptionsStore.setStatus('idle') + } + }, + }); +}
\ No newline at end of file diff --git a/web/composables/fetch-schedule.ts b/web/composables/fetch-schedule.ts index c061d92..a0e6fec 100644 --- a/web/composables/fetch-schedule.ts +++ b/web/composables/fetch-schedule.ts @@ -1,4 +1,4 @@ -export default function useFetchFavourites() { +export default function() { const scheduleStore = useScheduleStore(); const errorStore = useErrorStore(); const config = useRuntimeConfig(); @@ -10,7 +10,7 @@ export default function useFetchFavourites() { onResponse: ({ response }) => { if (!response.ok) { if (response.status === 401) { - navigateTo({ name: 'login', state: { error: 'Sorry, your session has expired' } }); + navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } }); } else { errorStore.setError(response._data.message || 'An unknown error occurred'); } @@ -18,9 +18,6 @@ export default function useFetchFavourites() { if (response._data) { scheduleStore.setSchedule((response._data as any).data.schedule); - errorStore.setError("Schedule set"); - } else { - errorStore.setError("Invalid response returned by server"); } }, }); diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index dc76af0..d5762e7 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -15,7 +15,7 @@ try { export default defineNuxtConfig({ compatibilityDate: "2024-11-01", devtools: { enabled: true }, - ssr: true, + ssr: false, css: ["~/assets/css/main.css"], runtimeConfig: { diff --git a/web/pages/agenda.vue b/web/pages/agenda.vue index 5e0c643..9b55c9b 100644 --- a/web/pages/agenda.vue +++ b/web/pages/agenda.vue @@ -14,9 +14,6 @@ const favouriteEvents = computed(() => { const calendarStatus = ref('pending' as 'pending' | 'idle'); const calendarLink = ref('') -const calendarLinkWithPageProtocol = computed(() => { - return window.location.protocol + '//' + calendarLink.value; -}); const refConfirmDeleteDialog = ref<typeof Dialog>(); @@ -97,7 +94,7 @@ function deleteCalendar() { <div v-else-if="calendarStatus === 'idle'" class="calendar"> <template v-if="calendarLink"> <span>You can add your agenda to your own calendar using the iCal link below</span> - <Input :value="calendarLinkWithPageProtocol" readonly/> + <Input :value="calendarLink" readonly/> <Button @click="refConfirmDeleteDialog!.show()" :loading="calendarAction">Delete calendar</Button> </template> <template v-else> diff --git a/web/pages/login.vue b/web/pages/login.vue deleted file mode 100644 index 2900a5e..0000000 --- a/web/pages/login.vue +++ /dev/null @@ -1,193 +0,0 @@ -<script setup lang="ts"> -import { ref } from 'vue' -import { FetchError } from 'ofetch' -import Input from '~/components/Input.vue' - -definePageMeta({ - layout: 'none' -}) - -const isLoading = ref(false) -const error = ref("") - -const config = useRuntimeConfig() -const headers = useRequestHeaders(['cookie']) - -const handleSubmit = async (e: Event) => { - const target = e.target as HTMLFormElement; - const formData = new FormData(target); - - isLoading.value = true - error.value = "" - - try { - await $fetch(config.public.baseURL + '/login', { - method: 'POST', - body: JSON.stringify(Object.fromEntries(formData)), - headers: headers, - server: false, - }); - - navigateTo("/"); - } catch (e: any) { - if ((e as FetchError).data) { - error.value = e.data.message - } else { - error.value = "An unknown error occurred" - } - } - - isLoading.value = false -} - -onMounted(() => { - if (history.state.error) { - error.value = history.state.error as string - } -}) - -</script> - -<template> - <div class="auth-container"> - <div class="auth-header"> - <h2 class="auth-title">Sign in</h2> - - <div v-if="error" class="auth-error"> - {{ error }} - </div> - </div> - - <div class="auth-body"> - <Panel> - <form class="auth-form" @submit.prevent="handleSubmit"> - <div class="form-group"> - <label for="username" class="form-label"> - Username - </label> - <div class="form-input-container"> - <Input id="username" name="username" required /> - </div> - </div> - - <div class="form-group"> - <label for="password" class="form-label"> - Password - </label> - <div class="form-input-container"> - <Input id="password" name="password" type="password" autocomplete="current-password" required /> - </div> - </div> - - - <div class="form-submit"> - <Button type="submit" :loading="isLoading"> - Sign in - </Button> - </div> - - <Version class="version" /> - </form> - </Panel> - - </div> - - <div class="form-footer"> - <NuxtLink to="/register" class="register-link"> - Register - </NuxtLink> - </div> - - </div> -</template> - -<style scoped> -div.auth-container { - min-height: 100vh; - background-color: var(--color-background-muted); - display: flex; - flex-direction: column; - justify-content: center; - gap: 1rem; -} - -div.auth-header { - margin: 0 auto; - width: 100%; - max-width: 28rem; - display: flex; - gap: 1rem; - align-items: center; - flex-direction: column; -} - -h2.auth-title { - margin-top: 1.5rem; - font-size: 1.875rem; - font-weight: 800; - color: #1f2937; -} - -div.auth-body { - margin-top: 2rem; - margin: 0 auto; - width: 100%; - max-width: 28rem; -} - -form.auth-form { - display: grid; - gap: 1.5rem; -} - -div.auth-error { - color: var(--color-text-error); - font-style: oblique; -} - -div.form-group { - display: flex; - flex-direction: column; -} - -label.form-label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: #374151; -} - -div.form-input-container { - margin-top: 0.25rem; -} - -div.form-footer { - display: flex; - justify-content: flex-end; - margin: 0 auto; - max-width: 28rem; -} - -div.form-submit { - display: flex; -} - -div.form-submit button { - width: 100%; -} - -.version { - font-size: var(--text-smaller); - margin: 0 auto; - color: var(--color-text-muted-light); -} - -.register-link { - font-size: var(--text-small); - font-weight: 500; -} - -input[name="username"] { - text-transform: lowercase; -} -</style> diff --git a/web/pages/login/[[provider]].vue b/web/pages/login/[[provider]].vue new file mode 100644 index 0000000..bfc7e69 --- /dev/null +++ b/web/pages/login/[[provider]].vue @@ -0,0 +1,302 @@ +<script setup lang="ts"> +import { ref } from 'vue' +import { FetchError } from 'ofetch' +import Input from '~/components/Input.vue' +import { useLoginOptionsStore } from '~/stores/login-options' + +definePageMeta({ + layout: 'none' +}) + +const authenticating = ref(false) +const authenticatingProvider = ref('') +const error = ref("") +const completingJourney = ref(false) +const basicAuthEnabled = ref(false) + +const route = useRoute() +const config = useRuntimeConfig() +const loginOptionsStore = useLoginOptionsStore() +const headers = useRequestHeaders(['cookie']) + +const { loginOptions, status } = storeToRefs(loginOptionsStore) + +watch(loginOptions, (options) => { + basicAuthEnabled.value = options.some(o => o.type === 'basic') +}) + +const handleBasicAuth = async (e: Event, providerName: string) => { + const target = e.target as HTMLFormElement; + const formData = new FormData(target); + + authenticating.value = true + authenticatingProvider.value = providerName + + try { + await $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + body: JSON.stringify(Object.fromEntries(formData)), + headers: headers, + server: false, + }); + + navigateTo("/"); + } catch (e: any) { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + authenticating.value = false + authenticatingProvider.value = '' + } +} + +const handleOIDCAuth = async (providerName: string) => { + authenticating.value = true + authenticatingProvider.value = providerName + + try { + let response: any = await $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + headers: headers, + server: false, + }); + navigateTo(response.data.url, { external: true }) + } catch (e: any) { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + authenticating.value = false + authenticatingProvider.value = '' + } +} + +onMounted(async () => { + if (history.state.error) { + error.value = history.state.error as string + } + + if (route.params.provider) { + completingJourney.value = true + + try { + let state = route.query.state + let code = route.query.code + + + let response: any = await $fetch(config.public.baseURL + '/login/' + route.params.provider, { + method: 'POST', + headers: headers, + server: false, + body: { + state: state, + code: code, + } + }); + + if (response.code === 307) { + throw Error() + } + + navigateTo("/"); + } catch (e: any) { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + completingJourney.value = false + fetchLogin() + } + return + } + + fetchLogin() +}) + +</script> + +<template> + <div class="auth-container"> + <div class="auth-header"> + <h2 class="auth-title">Sign in</h2> + + <div v-if="error" class="auth-error"> + {{ error }} + </div> + </div> + + <div class="auth-body"> + <Panel> + <div class="auth-form"> + <div v-if="completingJourney" class="spinner"> + <Spinner color="var(--color-text-muted)" />Completing login... + </div> + <div v-if="status === 'pending'" class="spinner"> + <Spinner color="var(--color-text-muted)" />Getting login options... + </div> + <div v-for="option in loginOptions"> + <form v-if="option.type === 'basic'" class="basic-form" @submit.prevent="(e) => handleBasicAuth(e, option.identifier)"> + <div class="form-group"> + <label for="username" class="form-label"> + Username + </label> + <div class="form-input-container"> + <Input id="username" name="username" required /> + </div> + </div> + + <div class="form-group"> + <label for="password" class="form-label"> + Password + </label> + <div class="form-input-container"> + <Input id="password" name="password" type="password" autocomplete="current-password" required /> + </div> + </div> + + + <div class="form-submit"> + <Button type="submit" :loading="authenticatingProvider === option.identifier" :disabled="authenticating"> + Sign in + </Button> + </div> + </form> + + <div v-if="option.type === 'oidc'" class="auth-provider"> + <Button type="button" :loading="authenticatingProvider === option.identifier" :disabled="authenticating" @click="(e) => handleOIDCAuth(option.identifier)"> + Sign in with {{ option.name }} + </Button> + </div> + </div> + + <Version class="version" /> + </div> + </Panel> + + </div> + + <div v-if="basicAuthEnabled" class="form-footer"> + <NuxtLink to="/register" class="register-link"> + Register + </NuxtLink> + </div> + + </div> +</template> + +<style scoped> +div.auth-container { + min-height: 100vh; + background-color: var(--color-background-muted); + display: flex; + flex-direction: column; + justify-content: center; + gap: 1rem; +} + +div.auth-header { + margin: 0 auto; + width: 100%; + max-width: 28rem; + display: flex; + gap: 1rem; + align-items: center; + flex-direction: column; +} + +h2.auth-title { + margin-top: 1.5rem; + font-size: 1.875rem; + font-weight: 800; + color: #1f2937; +} + +div.auth-body { + margin-top: 2rem; + margin: 0 auto; + width: 100%; + max-width: 28rem; +} + +div.auth-form { + display: grid; + gap: 1.5rem; +} + +form.basic-form { + display: grid; + gap: 1.5rem; +} + +div.auth-error { + color: var(--color-text-error); + font-style: oblique; +} + +div.form-group { + display: flex; + flex-direction: column; +} + +label.form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +div.form-input-container { + margin-top: 0.25rem; +} + +div.form-footer { + display: flex; + justify-content: flex-end; + margin: 0 auto; + max-width: 28rem; +} + +div.form-submit { + display: flex; +} + +div.form-submit button { + width: 100%; +} + +.version { + font-size: var(--text-smaller); + margin: 0 auto; + color: var(--color-text-muted-light); +} + +.auth-provider button { + display: flex; + width: 100%; +} + +.register-link { + font-size: var(--text-small); + font-weight: 500; +} + +input[name="username"] { + text-transform: lowercase; +} + +.spinner { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + font-size: var(--text-normal); + color: var(--color-text-muted); +} +</style> diff --git a/web/stores/login-options.ts b/web/stores/login-options.ts new file mode 100644 index 0000000..fd97a75 --- /dev/null +++ b/web/stores/login-options.ts @@ -0,0 +1,22 @@ +import { defineStore } from "pinia"; + +interface LoginOption { + name: string; + identifier: string; + type: string; +} + +export const useLoginOptionsStore = defineStore('loginOptions', () => { + const loginOptions = ref([] as LoginOption[]) + const status = ref('idle' as 'idle' | 'pending') + + const setLoginOptions = (newLoginOptions: LoginOption[]) => { + loginOptions.value = newLoginOptions + } + + const setStatus = (newStatus: 'idle' | 'pending') => { + status.value = newStatus + } + + return {loginOptions, status, setLoginOptions, setStatus} +}) @@ -4,25 +4,33 @@ import ( "embed" "io/fs" "net/http" + "regexp" ) -//go:generate npm ci +//go:generate npm install //go:generate npm run generate //go:embed all:.output/public var fsys embed.FS +var urlFileRegexp = regexp.MustCompile(`[\w\-/]+\.[a-zA-Z]+$`) type WebFileServer struct { - server http.Handler + root fs.FS + handler http.Handler } func NewWebFileServer() *WebFileServer { fsys, _ := fs.Sub(fsys, ".output/public") return &WebFileServer{ - server: http.FileServerFS(fsys), + root: fsys, + handler: http.FileServerFS(fsys), } } func (fs *WebFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - fs.server.ServeHTTP(w, r) + if p := r.URL.Path; p != "/" && !urlFileRegexp.MatchString(p) { + http.ServeFileFS(w, r, fs.root, "index.html") + return + } + fs.handler.ServeHTTP(w, r) } |
