aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/dto/auth.go30
-rw-r--r--api/dto/users.go10
-rw-r--r--api/handlers/auth.go148
-rw-r--r--api/handlers/calendar.go2
-rw-r--r--api/handlers/users.go53
-rw-r--r--api/router.go9
-rw-r--r--go.mod16
-rw-r--r--go.sum23
-rw-r--r--internal/config/config.go15
-rw-r--r--main.go28
-rw-r--r--pkg/auth/basic.go56
-rw-r--r--pkg/auth/oauth.go191
-rw-r--r--pkg/auth/service.go49
-rw-r--r--pkg/database/migrations/0002_nullable_passwords.sql4
-rw-r--r--pkg/database/sqlc/calendars.sql.go2
-rw-r--r--pkg/database/sqlc/db.go2
-rw-r--r--pkg/database/sqlc/favourites.sql.go2
-rw-r--r--pkg/database/sqlc/models.go8
-rw-r--r--pkg/database/sqlc/users.sql.go8
-rw-r--r--pkg/session/memory.go4
-rw-r--r--pkg/user/service.go49
-rw-r--r--web/composables/fetch-favourites.ts2
-rw-r--r--web/composables/fetch-login.ts25
-rw-r--r--web/composables/fetch-schedule.ts7
-rw-r--r--web/nuxt.config.ts2
-rw-r--r--web/pages/agenda.vue5
-rw-r--r--web/pages/login.vue193
-rw-r--r--web/pages/login/[[provider]].vue302
-rw-r--r--web/stores/login-options.ts22
-rw-r--r--web/web.go16
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)))
diff --git a/go.mod b/go.mod
index 0e39102..1030a19 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 3964c10..d18ea1f 100644
--- a/go.sum
+++ b/go.sum
@@ -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 {
diff --git a/main.go b/main.go
index b3c6165..545ac82 100644
--- a/main.go
+++ b/main.go
@@ -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}
+})
diff --git a/web/web.go b/web/web.go
index 81ed6be..d823d46 100644
--- a/web/web.go
+++ b/web/web.go
@@ -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)
}