diff --git a/api/health.go b/api/health.go index dd26ca1e..7c028817 100644 --- a/api/health.go +++ b/api/health.go @@ -16,7 +16,7 @@ type HealthAPI struct { } // Health returns health information. -// swagger:operation GET /health health getHealth +// swagger:operation GET /health info getHealth // // Get health information. // diff --git a/api/oidc.go b/api/oidc.go new file mode 100644 index 00000000..4b7cb35f --- /dev/null +++ b/api/oidc.go @@ -0,0 +1,362 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" + "github.com/gotify/server/v2/config" + "github.com/gotify/server/v2/database" + "github.com/gotify/server/v2/model" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI { + scopes := conf.OIDC.Scopes + if len(scopes) == 0 { + scopes = []string{"openid", "profile", "email"} + } + + cookieKey := make([]byte, 32) + if _, err := rand.Read(cookieKey); err != nil { + log.Fatalf("failed to generate OIDC cookie key: %v", err) + } + cookieHandlerOpt := []httphelper.CookieHandlerOpt{} + if !conf.Server.SecureCookie { + cookieHandlerOpt = append(cookieHandlerOpt, httphelper.WithUnsecure()) + } + cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...) + + opts := []rp.Option{rp.WithCookieHandler(cookieHandler), rp.WithPKCE(cookieHandler)} + + provider, err := rp.NewRelyingPartyOIDC( + context.Background(), + conf.OIDC.Issuer, + conf.OIDC.ClientID, + conf.OIDC.ClientSecret, + conf.OIDC.RedirectURL, + scopes, + opts..., + ) + if err != nil { + log.Fatalf("failed to initialize OIDC provider: %v", err) + } + + return &OIDCAPI{ + DB: db, + Provider: provider, + UserChangeNotifier: userChangeNotifier, + UsernameClaim: conf.OIDC.UsernameClaim, + PasswordStrength: conf.PassStrength, + SecureCookie: conf.Server.SecureCookie, + AutoRegister: conf.OIDC.AutoRegister, + pendingSessions: make(map[string]*pendingOIDCSession), + } +} + +const pendingSessionMaxAge = 10 * time.Minute + +type pendingOIDCSession struct { + RedirectURI string + ClientName string + CreatedAt time.Time +} + +// OIDCAPI provides handlers for OIDC authentication. +type OIDCAPI struct { + DB *database.GormDatabase + Provider rp.RelyingParty + UserChangeNotifier *UserChangeNotifier + UsernameClaim string + PasswordStrength int + SecureCookie bool + AutoRegister bool + pendingSessions map[string]*pendingOIDCSession + pendingSessionsMu sync.Mutex +} + +func (a *OIDCAPI) storePendingSession(state string, session *pendingOIDCSession) { + a.pendingSessionsMu.Lock() + defer a.pendingSessionsMu.Unlock() + for s, sess := range a.pendingSessions { + if time.Since(sess.CreatedAt) > pendingSessionMaxAge { + delete(a.pendingSessions, s) + } + } + a.pendingSessions[state] = session +} + +func (a *OIDCAPI) popPendingSession(state string) (*pendingOIDCSession, bool) { + a.pendingSessionsMu.Lock() + session, ok := a.pendingSessions[state] + if ok { + delete(a.pendingSessions, state) + } + a.pendingSessionsMu.Unlock() + if !ok || time.Since(session.CreatedAt) > pendingSessionMaxAge { + return nil, false + } + return session, true +} + +// swagger:operation GET /auth/oidc/login oidc oidcLogin +// +// Start the OIDC login flow (browser). +// +// Redirects the user to the OIDC provider's authorization endpoint. +// After authentication, the provider redirects back to the callback endpoint. +// +// --- +// parameters: +// - name: name +// in: query +// description: the client name to create after login +// required: true +// type: string +// responses: +// 302: +// description: Redirect to OIDC provider +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) LoginHandler() gin.HandlerFunc { + return gin.WrapF(func(w http.ResponseWriter, r *http.Request) { + clientName := r.URL.Query().Get("name") + if clientName == "" { + http.Error(w, "invalid client name", http.StatusBadRequest) + return + } + state, err := a.generateState(clientName) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError) + return + } + rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r) + }) +} + +// swagger:operation GET /auth/oidc/callback oidc oidcCallback +// +// Handle the OIDC provider callback (browser). +// +// Exchanges the authorization code for tokens, resolves the user, +// creates a gotify client, sets a session cookie, and redirects to the UI. +// +// --- +// parameters: +// - name: code +// in: query +// description: the authorization code from the OIDC provider +// required: true +// type: string +// - name: state +// in: query +// description: the state parameter for CSRF protection +// required: true +// type: string +// responses: +// 307: +// description: Redirect to UI +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc { + callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) { + user, status, err := a.resolveUser(info) + if err != nil { + http.Error(w, err.Error(), status) + return + } + clientName, _, _ := strings.Cut(state, ":") + client, err := a.createClient(clientName, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError) + return + } + auth.SetCookie(w, client.Token, auth.CookieMaxAge, a.SecureCookie) + // A reverse proxy may have already stripped a url prefix from the URL + // without us knowing, we have to make a relative redirect. + // We cannot use http.Redirect as this normalizes the Path with r.URL. + w.Header().Set("Location", "../../") + w.WriteHeader(http.StatusTemporaryRedirect) + } + return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider)) +} + +// swagger:operation POST /auth/oidc/external/authorize oidc externalAuthorize +// +// Initiate the OIDC authorization flow for a native app. +// +// The app generates a PKCE code_verifier and code_challenge, then calls this +// endpoint. The server forwards the code_challenge to the OIDC provider and +// returns the authorization URL for the app to open in a browser. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// parameters: +// - name: body +// in: body +// required: true +// schema: +// $ref: "#/definitions/OIDCExternalAuthorizeRequest" +// responses: +// 200: +// description: Ok +// schema: +// $ref: "#/definitions/OIDCExternalAuthorizeResponse" +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) ExternalAuthorizeHandler(ctx *gin.Context) { + var req model.OIDCExternalAuthorizeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } + state, err := a.generateState(req.Name) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + a.storePendingSession(state, &pendingOIDCSession{ + RedirectURI: req.RedirectURI, ClientName: req.Name, CreatedAt: time.Now(), + }) + authOpts := []rp.AuthURLOpt{ + rp.AuthURLOpt(rp.WithURLParam("redirect_uri", req.RedirectURI)), + rp.WithCodeChallenge(req.CodeChallenge), + } + ctx.JSON(http.StatusOK, &model.OIDCExternalAuthorizeResponse{ + AuthorizeURL: rp.AuthURL(state, a.Provider, authOpts...), + State: state, + }) +} + +// swagger:operation POST /auth/oidc/external/token oidc externalToken +// +// Exchange an authorization code for a gotify client token. +// +// After the user authenticates with the OIDC provider and the app receives +// the authorization code via redirect, the app calls this endpoint with the +// code and PKCE code_verifier. The server exchanges the code with the OIDC +// provider and returns a gotify client token. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// parameters: +// - name: body +// in: body +// required: true +// schema: +// $ref: "#/definitions/OIDCExternalTokenRequest" +// responses: +// 200: +// description: Ok +// schema: +// $ref: "#/definitions/OIDCExternalTokenResponse" +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) { + var req model.OIDCExternalTokenRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } + session, ok := a.popPendingSession(req.State) + if !ok { + ctx.AbortWithError(http.StatusBadRequest, errors.New("unknown or expired state")) + return + } + exchangeOpts := []rp.CodeExchangeOpt{ + rp.CodeExchangeOpt(rp.WithURLParam("redirect_uri", session.RedirectURI)), + rp.WithCodeVerifier(req.CodeVerifier), + } + tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx.Request.Context(), req.Code, a.Provider, exchangeOpts...) + if err != nil { + ctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf("token exchange failed: %w", err)) + return + } + info, err := rp.Userinfo[*oidc.UserInfo](ctx.Request.Context(), tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), a.Provider) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err)) + return + } + user, status, resolveErr := a.resolveUser(info) + if resolveErr != nil { + ctx.AbortWithError(status, resolveErr) + return + } + client, err := a.createClient(session.ClientName, user.ID) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + ctx.JSON(http.StatusOK, &model.OIDCExternalTokenResponse{ + Token: client.Token, + User: &model.UserExternal{ID: user.ID, Name: user.Name, Admin: user.Admin}, + }) +} + +func (a *OIDCAPI) generateState(name string) (string, error) { + nonce := make([]byte, 20) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + return name + ":" + hex.EncodeToString(nonce), nil +} + +// resolveUser looks up or creates a user from OIDC userinfo claims. +func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) { + usernameRaw, ok := info.Claims[a.UsernameClaim] + if !ok { + return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim) + } + username := fmt.Sprint(usernameRaw) + if username == "" || usernameRaw == nil { + return nil, http.StatusInternalServerError, fmt.Errorf("username claim was empty") + } + + user, err := a.DB.GetUserByName(username) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err) + } + if user == nil { + if !a.AutoRegister { + return nil, http.StatusForbidden, fmt.Errorf("user does not exist and auto-registration is disabled") + } + user = &model.User{Name: username, Admin: false, Pass: nil} + if err := a.DB.CreateUser(user); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err) + } + if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil { + log.Printf("Could not notify user change: %v\n", err) + } + } + return user, 0, nil +} + +func (a *OIDCAPI) createClient(name string, userID uint) (*model.Client, error) { + client := &model.Client{ + Name: name, + Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), + UserID: userID, + } + return client, a.DB.CreateClient(client) +} diff --git a/api/oidc_test.go b/api/oidc_test.go new file mode 100644 index 00000000..b3376a9f --- /dev/null +++ b/api/oidc_test.go @@ -0,0 +1,234 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/mode" + "github.com/gotify/server/v2/test" + "github.com/gotify/server/v2/test/testdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var origGenClientToken = generateClientToken + +func TestOIDCSuite(t *testing.T) { + suite.Run(t, new(OIDCSuite)) +} + +type OIDCSuite struct { + suite.Suite + db *testdb.Database + a *OIDCAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder + notified bool +} + +func (s *OIDCSuite) BeforeTest(suiteName, testName string) { + mode.Set(mode.TestDev) + s.recorder = httptest.NewRecorder() + s.ctx, _ = gin.CreateTestContext(s.recorder) + s.db = testdb.NewDB(s.T()) + s.notified = false + notifier := new(UserChangeNotifier) + notifier.OnUserAdded(func(uint) error { + s.notified = true + return nil + }) + s.a = &OIDCAPI{ + DB: s.db.GormDatabase, + UserChangeNotifier: notifier, + UsernameClaim: "preferred_username", + AutoRegister: true, + pendingSessions: make(map[string]*pendingOIDCSession), + } +} + +func (s *OIDCSuite) AfterTest(suiteName, testName string) { + s.db.Close() +} + +func (s *OIDCSuite) Test_GenerateState_Unique() { + s1, _ := s.a.generateState("app") + s2, _ := s.a.generateState("app") + assert.NotEqual(s.T(), s1, s2) +} + +func (s *OIDCSuite) Test_StoreAndPopPendingSession() { + session := &pendingOIDCSession{RedirectURI: "app://cb", ClientName: "phone", CreatedAt: time.Now()} + s.a.storePendingSession("state1", session) + + got, ok := s.a.popPendingSession("state1") + assert.True(s.T(), ok) + assert.Equal(s.T(), "app://cb", got.RedirectURI) + + // second pop returns nothing (consumed) + _, ok = s.a.popPendingSession("state1") + assert.False(s.T(), ok) +} + +func (s *OIDCSuite) Test_PopPendingSession_UnknownState() { + _, ok := s.a.popPendingSession("doesnotexist") + assert.False(s.T(), ok) +} + +func (s *OIDCSuite) Test_PopPendingSession_Expired() { + session := &pendingOIDCSession{RedirectURI: "x", ClientName: "x", CreatedAt: time.Now().Add(-11 * time.Minute)} + s.a.storePendingSession("old", session) + + _, ok := s.a.popPendingSession("old") + assert.False(s.T(), ok) +} + +func (s *OIDCSuite) Test_StorePendingSession_PrunesExpired() { + expired := &pendingOIDCSession{CreatedAt: time.Now().Add(-11 * time.Minute)} + s.a.pendingSessions["stale"] = expired + + fresh := &pendingOIDCSession{CreatedAt: time.Now()} + s.a.storePendingSession("fresh", fresh) + + s.a.pendingSessionsMu.Lock() + _, staleExists := s.a.pendingSessions["stale"] + _, freshExists := s.a.pendingSessions["fresh"] + s.a.pendingSessionsMu.Unlock() + + assert.False(s.T(), staleExists) + assert.True(s.T(), freshExists) +} + +func (s *OIDCSuite) Test_ResolveUser_ExistingUser() { + s.db.NewUserWithName(1, "alice") + + info := &oidc.UserInfo{Claims: map[string]any{"preferred_username": "alice"}} + user, status, err := s.a.resolveUser(info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.Equal(s.T(), "alice", user.Name) + assert.Equal(s.T(), uint(1), user.ID) + assert.False(s.T(), s.notified) +} + +func (s *OIDCSuite) Test_ResolveUser_AutoRegister() { + info := &oidc.UserInfo{Claims: map[string]any{"preferred_username": "newuser"}} + user, status, err := s.a.resolveUser(info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.Equal(s.T(), "newuser", user.Name) + assert.False(s.T(), user.Admin) + assert.True(s.T(), s.notified) + + // verify persisted + dbUser, err := s.db.GetUserByName("newuser") + assert.NoError(s.T(), err) + assert.NotNil(s.T(), dbUser) +} + +func (s *OIDCSuite) Test_ResolveUser_AutoRegisterDisabled() { + s.a.AutoRegister = false + info := &oidc.UserInfo{Claims: map[string]any{"preferred_username": "newuser"}} + + _, status, err := s.a.resolveUser(info) + + assert.Error(s.T(), err) + assert.Equal(s.T(), 403, status) + s.db.AssertUsernameNotExist("newuser") +} + +func (s *OIDCSuite) Test_ResolveUser_MissingClaim() { + info := &oidc.UserInfo{Claims: map[string]any{}} + + _, status, err := s.a.resolveUser(info) + + assert.Error(s.T(), err) + assert.Equal(s.T(), 500, status) + assert.Contains(s.T(), err.Error(), "preferred_username") +} + +func (s *OIDCSuite) Test_ResolveUser_EmptyClaim() { + info := &oidc.UserInfo{Claims: map[string]any{"preferred_username": ""}} + + _, status, err := s.a.resolveUser(info) + + assert.Error(s.T(), err) + assert.Equal(s.T(), 500, status) +} + +func (s *OIDCSuite) Test_ResolveUser_NilClaim() { + info := &oidc.UserInfo{Claims: map[string]any{"preferred_username": nil}} + + _, status, err := s.a.resolveUser(info) + + assert.Error(s.T(), err) + assert.Equal(s.T(), 500, status) +} + +func (s *OIDCSuite) Test_ResolveUser_CustomClaim() { + s.a.UsernameClaim = "email" + s.db.NewUserWithName(1, "alice@example.com") + + info := &oidc.UserInfo{Claims: map[string]any{"email": "alice@example.com"}} + user, _, err := s.a.resolveUser(info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), "alice@example.com", user.Name) +} + +// --- createClient --- + +func (s *OIDCSuite) Test_CreateClient() { + generateClientToken = test.Tokens("Ctesttoken00001") + defer func() { generateClientToken = origGenClientToken }() + + s.db.NewUser(1) + client, err := s.a.createClient("MyPhone", 1) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), "MyPhone", client.Name) + assert.Equal(s.T(), "Ctesttoken00001", client.Token) + assert.Equal(s.T(), uint(1), client.UserID) + + dbClient, err := s.db.GetClientByToken("Ctesttoken00001") + assert.NoError(s.T(), err) + assert.NotNil(s.T(), dbClient) +} + +// --- ExternalAuthorizeHandler --- + +func (s *OIDCSuite) Test_ExternalAuthorizeHandler_MissingFields() { + s.ctx.Request = httptest.NewRequest("POST", "/auth/oidc/external/authorize", strings.NewReader(`{}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.ExternalAuthorizeHandler(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +// --- ExternalTokenHandler --- + +func (s *OIDCSuite) Test_ExternalTokenHandler_InvalidJSON() { + s.ctx.Request = httptest.NewRequest("POST", "/auth/oidc/external/token", strings.NewReader(`{bad`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.ExternalTokenHandler(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *OIDCSuite) Test_ExternalTokenHandler_UnknownState() { + s.ctx.Request = httptest.NewRequest("POST", "/auth/oidc/external/token", strings.NewReader( + `{"code":"abc","state":"bogus:1234","code_verifier":"v"}`, + )) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.ExternalTokenHandler(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} diff --git a/api/session.go b/api/session.go new file mode 100644 index 00000000..5e581484 --- /dev/null +++ b/api/session.go @@ -0,0 +1,98 @@ +package api + +import ( + "errors" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" + "github.com/gotify/server/v2/auth/password" + "github.com/gotify/server/v2/model" +) + +// SessionDatabase is the interface for session-related database access. +type SessionDatabase interface { + GetUserByName(name string) (*model.User, error) + CreateClient(client *model.Client) error + GetClientByToken(token string) (*model.Client, error) + DeleteClientByID(id uint) error +} + +// SessionAPI provides handlers for cookie-based session authentication. +type SessionAPI struct { + DB SessionDatabase + NotifyDeleted func(uint, string) + SecureCookie bool +} + +// Login authenticates via basic auth, creates a client, sets an HttpOnly cookie, and returns user info. +func (a *SessionAPI) Login(ctx *gin.Context) { + name, pass, ok := ctx.Request.BasicAuth() + if !ok { + ctx.AbortWithError(401, errors.New("basic auth required")) + return + } + + user, err := a.DB.GetUserByName(name) + if err != nil { + ctx.AbortWithError(500, err) + return + } + if user == nil || !password.ComparePassword(user.Pass, []byte(pass)) { + ctx.AbortWithError(401, errors.New("invalid credentials")) + return + } + + clientParams := ClientParams{} + if err := ctx.Bind(&clientParams); err != nil { + return + } + + client := model.Client{ + Name: clientParams.Name, + Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + UserID: user.ID, + } + if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { + return + } + + auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge, a.SecureCookie) + + ctx.JSON(200, &model.UserExternal{ + ID: user.ID, + Name: user.Name, + Admin: user.Admin, + }) +} + +// Logout deletes the client for the current session and clears the cookie. +func (a *SessionAPI) Logout(ctx *gin.Context) { + auth.SetCookie(ctx.Writer, "", -1, a.SecureCookie) + + tokenID := auth.TryGetTokenID(ctx) + if tokenID == "" { + ctx.AbortWithError(400, errors.New("no client auth provided")) + return + } + client, err := a.DB.GetClientByToken(tokenID) + if err != nil { + ctx.AbortWithError(500, err) + return + } + if client == nil { + ctx.Status(200) + return + } + + a.NotifyDeleted(client.UserID, client.Token) + if success := successOrAbort(ctx, 500, a.DB.DeleteClientByID(client.ID)); !success { + return + } + + ctx.Status(200) +} + +func (a *SessionAPI) clientExists(token string) bool { + client, _ := a.DB.GetClientByToken(token) + return client != nil +} diff --git a/api/session_test.go b/api/session_test.go new file mode 100644 index 00000000..d4afcad2 --- /dev/null +++ b/api/session_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" + "github.com/gotify/server/v2/auth/password" + "github.com/gotify/server/v2/mode" + "github.com/gotify/server/v2/model" + "github.com/gotify/server/v2/test" + "github.com/gotify/server/v2/test/testdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestSessionSuite(t *testing.T) { + suite.Run(t, new(SessionSuite)) +} + +type SessionSuite struct { + suite.Suite + db *testdb.Database + a *SessionAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder + notified bool +} + +func (s *SessionSuite) BeforeTest(suiteName, testName string) { + mode.Set(mode.TestDev) + s.recorder = httptest.NewRecorder() + s.db = testdb.NewDB(s.T()) + s.ctx, _ = gin.CreateTestContext(s.recorder) + withURL(s.ctx, "http", "example.com") + s.notified = false + s.a = &SessionAPI{DB: s.db, NotifyDeleted: s.notify} + + s.db.CreateUser(&model.User{ + Name: "testuser", + Pass: password.CreatePassword("testpass", 5), + }) +} + +func (s *SessionSuite) notify(uint, string) { + s.notified = true +} + +func (s *SessionSuite) AfterTest(suiteName, testName string) { + s.db.Close() +} + +func (s *SessionSuite) Test_Login_Success() { + originalGenerateClientToken := generateClientToken + defer func() { generateClientToken = originalGenerateClientToken }() + generateClientToken = test.Tokens("Ctesttoken12345") + + s.ctx.Request = httptest.NewRequest("POST", "/auth/local/login", strings.NewReader("name=test-browser")) + s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ctx.Request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))) + + s.a.Login(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + + // Verify HttpOnly cookie is set + cookies := s.recorder.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == auth.CookieName { + sessionCookie = c + break + } + } + assert.NotNil(s.T(), sessionCookie) + assert.Equal(s.T(), "Ctesttoken12345", sessionCookie.Value) + assert.True(s.T(), sessionCookie.HttpOnly) + assert.Equal(s.T(), "/", sessionCookie.Path) + assert.Equal(s.T(), http.SameSiteStrictMode, sessionCookie.SameSite) + + body := s.recorder.Body.String() + assert.Contains(s.T(), body, "testuser") + assert.NotContains(s.T(), body, "Ctesttoken12345") + + clients, err := s.db.GetClientsByUser(1) + assert.NoError(s.T(), err) + assert.Len(s.T(), clients, 1) + assert.Equal(s.T(), "test-browser", clients[0].Name) +} + +func (s *SessionSuite) Test_Login_WrongPassword() { + s.ctx.Request = httptest.NewRequest("POST", "/auth/local/login", strings.NewReader("name=test-browser")) + s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ctx.Request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:wrongpass"))) + + s.a.Login(s.ctx) + + assert.Equal(s.T(), 401, s.recorder.Code) + + // No cookie should be set + cookies := s.recorder.Result().Cookies() + for _, c := range cookies { + assert.NotEqual(s.T(), auth.CookieName, c.Name) + } +} + +func (s *SessionSuite) Test_Logout_Success() { + builder := s.db.User(5) + builder.ClientWithToken(1, "Ctesttoken12345") + + s.ctx.Request = httptest.NewRequest("POST", "/auth/local/logout", nil) + test.WithUser(s.ctx, 5) + s.ctx.Set("tokenid", "Ctesttoken12345") + + s.a.Logout(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.True(s.T(), s.notified) + + cookies := s.recorder.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == auth.CookieName { + sessionCookie = c + break + } + } + assert.NotNil(s.T(), sessionCookie) + assert.Equal(s.T(), "", sessionCookie.Value) + assert.True(s.T(), sessionCookie.MaxAge < 0) + + s.db.AssertClientNotExist(1) +} diff --git a/auth/authentication.go b/auth/authentication.go index 295d67b6..df84f6c5 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -10,15 +10,23 @@ import ( "github.com/gotify/server/v2/model" ) +type authState int + +const ( + authStateSkip authState = iota + authStateForbidden + authStateOk +) + const ( headerName = "X-Gotify-Key" + cookieName = "gotify-client-token" ) // The Database interface for encapsulating database access. type Database interface { GetApplicationByToken(token string) (*model.Application, error) GetClientByToken(token string) (*model.Client, error) - GetPluginConfByToken(token string) (*model.PluginConf, error) GetUserByName(name string) (*model.User, error) GetUserByID(id uint) (*model.User, error) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error @@ -27,83 +35,182 @@ type Database interface { // Auth is the provider for authentication middleware. type Auth struct { - DB Database + DB Database + SecureCookie bool } -type authenticate func(tokenID string, user *model.User) (authenticated, success bool, userId uint, err error) - // RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied // with the request. Also the authenticated user must be an administrator. -func (a *Auth) RequireAdmin() gin.HandlerFunc { - return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { - if user != nil { - return true, user.Admin, user.ID, nil - } - if token, err := a.DB.GetClientByToken(tokenID); err != nil { - return false, false, 0, err - } else if token != nil { - user, err := a.DB.GetUserByID(token.UserID) - if err != nil { - return false, false, token.UserID, err - } - return true, user.Admin, token.UserID, nil - } - return false, false, 0, nil - }) +func (a *Auth) RequireAdmin(ctx *gin.Context) { + a.evaluateOr401(ctx, a.user(true), a.client(true)) } // RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied // with the request. -func (a *Auth) RequireClient() gin.HandlerFunc { - return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { - if user != nil { - return true, true, user.ID, nil +func (a *Auth) RequireClient(ctx *gin.Context) { + a.evaluateOr401(ctx, a.user(false), a.client(false)) +} + +// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request. +func (a *Auth) RequireApplicationToken(ctx *gin.Context) { + if a.evaluate(ctx, a.application) { + return + } + state, err := a.user(false)(ctx) + if err != nil { + ctx.AbortWithError(500, err) + } + if state != authStateSkip { + // Return to the user that it's valid authentication, but we don't allow user auth for application endpoints. + a.abort403(ctx) + return + } + a.abort401(ctx) +} + +func (a *Auth) Optional(ctx *gin.Context) { + if !a.evaluate(ctx, a.user(false), a.client(false)) { + RegisterAuthentication(ctx, nil, 0, "") + ctx.Next() + } +} + +func (a *Auth) evaluate(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authState, error)) bool { + for _, fn := range funcs { + state, err := fn(ctx) + if err != nil { + ctx.AbortWithError(500, err) + return true } - if client, err := a.DB.GetClientByToken(tokenID); err != nil { - return false, false, 0, err - } else if client != nil { - now := time.Now() - if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { - if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil { - return false, false, 0, err + switch state { + case authStateForbidden: + a.abort403(ctx) + return true + case authStateOk: + ctx.Next() + return true + case authStateSkip: + continue + } + } + return false +} + +func (a *Auth) evaluateOr401(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authState, error)) { + if !a.evaluate(ctx, funcs...) { + a.abort401(ctx) + } +} + +func (a *Auth) abort401(ctx *gin.Context) { + ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api")) +} + +func (a *Auth) abort403(ctx *gin.Context) { + ctx.AbortWithError(403, errors.New("you are not allowed to access this api")) +} + +func (a *Auth) user(requireAdmin bool) func(ctx *gin.Context) (authState, error) { + return func(ctx *gin.Context) (authState, error) { + if name, pass, ok := ctx.Request.BasicAuth(); ok { + if user, err := a.DB.GetUserByName(name); err != nil { + return authStateSkip, err + } else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) { + RegisterAuthentication(ctx, user, user.ID, "") + + if requireAdmin && !user.Admin { + return authStateForbidden, nil } + return authStateOk, nil } - return true, true, client.UserID, nil } - return false, false, 0, nil - }) + return authStateSkip, nil + } } -// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request. -func (a *Auth) RequireApplicationToken() gin.HandlerFunc { - return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { - if user != nil { - return true, false, 0, nil +func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, error) { + return func(ctx *gin.Context) (authState, error) { + token, isCookie := a.readTokenFromRequest(ctx) + if token == "" { + return authStateSkip, nil } - if app, err := a.DB.GetApplicationByToken(tokenID); err != nil { - return false, false, 0, err - } else if app != nil { - now := time.Now() - if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { - if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil { - return false, false, 0, err - } + client, err := a.DB.GetClientByToken(token) + if err != nil { + return authStateSkip, err + } + if client == nil { + return authStateSkip, nil + } + RegisterAuthentication(ctx, nil, client.UserID, client.Token) + + now := time.Now() + if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { + if err := a.DB.UpdateClientTokensLastUsed([]string{client.Token}, &now); err != nil { + return authStateSkip, err + } + if isCookie { + SetCookie(ctx.Writer, client.Token, CookieMaxAge, a.SecureCookie) } - return true, true, app.UserID, nil } - return false, false, 0, nil - }) + + if requireAdmin { + if user, err := a.DB.GetUserByID(client.UserID); err != nil { + return authStateSkip, err + } else if !user.Admin { + return authStateForbidden, nil + } + } + + return authStateOk, nil + } +} + +func (a *Auth) application(ctx *gin.Context) (authState, error) { + token, isCookie := a.readTokenFromRequest(ctx) + if token == "" { + return authStateSkip, nil + } + app, err := a.DB.GetApplicationByToken(token) + if err != nil { + return authStateSkip, err + } + if app == nil { + return authStateSkip, nil + } + RegisterAuthentication(ctx, nil, app.UserID, app.Token) + + now := time.Now() + if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { + if err := a.DB.UpdateApplicationTokenLastUsed(app.Token, &now); err != nil { + return authStateSkip, err + } + if isCookie { + SetCookie(ctx.Writer, app.Token, CookieMaxAge, a.SecureCookie) + } + } + + return authStateOk, nil } -func (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string { +func (a *Auth) readTokenFromRequest(ctx *gin.Context) (string, bool) { if token := a.tokenFromQuery(ctx); token != "" { - return token + return token, false } else if token := a.tokenFromXGotifyHeader(ctx); token != "" { - return token + return token, false } else if token := a.tokenFromAuthorizationHeader(ctx); token != "" { - return token + return token, false + } else if token := a.tokenFromCookie(ctx); token != "" { + return token, true } - return "" + return "", false +} + +func (a *Auth) tokenFromCookie(ctx *gin.Context) string { + token, err := ctx.Cookie(cookieName) + if err != nil { + return "" + } + return token } func (a *Auth) tokenFromQuery(ctx *gin.Context) string { @@ -128,67 +235,3 @@ func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string { return authHeader[len(prefix):] } - -func (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) { - if name, pass, ok := ctx.Request.BasicAuth(); ok { - if user, err := a.DB.GetUserByName(name); err != nil { - return nil, err - } else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) { - return user, nil - } - } - return nil, nil -} - -func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc { - return func(ctx *gin.Context) { - token := a.tokenFromQueryOrHeader(ctx) - user, err := a.userFromBasicAuth(ctx) - if err != nil { - ctx.AbortWithError(500, errors.New("an error occurred while authenticating user")) - return - } - - if user != nil || token != "" { - authenticated, ok, userID, err := auth(token, user) - if err != nil { - ctx.AbortWithError(500, errors.New("an error occurred while authenticating user")) - return - } else if ok { - RegisterAuthentication(ctx, user, userID, token) - ctx.Next() - return - } else if authenticated { - ctx.AbortWithError(403, errors.New("you are not allowed to access this api")) - return - } - } - ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api")) - } -} - -func (a *Auth) Optional() gin.HandlerFunc { - return func(ctx *gin.Context) { - token := a.tokenFromQueryOrHeader(ctx) - user, err := a.userFromBasicAuth(ctx) - if err != nil { - RegisterAuthentication(ctx, nil, 0, "") - ctx.Next() - return - } - - if user != nil { - RegisterAuthentication(ctx, user, user.ID, token) - ctx.Next() - return - } else if token != "" { - if tokenClient, err := a.DB.GetClientByToken(token); err == nil && tokenClient != nil { - RegisterAuthentication(ctx, user, tokenClient.UserID, token) - ctx.Next() - return - } - } - RegisterAuthentication(ctx, nil, 0, "") - ctx.Next() - } -} diff --git a/auth/authentication_test.go b/auth/authentication_test.go index 92efff43..154a67ad 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "net/http" "net/http/httptest" "testing" @@ -27,7 +28,7 @@ type AuthenticationSuite struct { func (s *AuthenticationSuite) SetupSuite() { mode.Set(mode.TestDev) s.DB = testdb.NewDB(s.T()) - s.auth = &Auth{s.DB} + s.auth = &Auth{DB: s.DB} s.DB.CreateUser(&model.User{ Name: "existing", @@ -82,7 +83,7 @@ func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddlewar recorder := httptest.NewRecorder() ctx, _ = gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/?%s=%s", key, value), nil) - f()(ctx) + f(ctx) assert.Equal(s.T(), code, recorder.Code) return ctx } @@ -91,7 +92,7 @@ func (s *AuthenticationSuite) TestNothingProvided() { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", "/", nil) - s.auth.RequireApplicationToken()(ctx) + s.auth.RequireApplicationToken(ctx) assert.Equal(s.T(), 401, recorder.Code) } @@ -210,14 +211,43 @@ func (s *AuthenticationSuite) TestOptionalAuth() { assert.Equal(s.T(), uint(2), *TryGetUserID(ctx)) } +func (s *AuthenticationSuite) TestCookieToken() { + // not existing token + s.assertCookieRequest("ergerogerg", s.auth.RequireApplicationToken, 401) + s.assertCookieRequest("ergerogerg", s.auth.RequireClient, 401) + s.assertCookieRequest("ergerogerg", s.auth.RequireAdmin, 401) + + // apptoken + s.assertCookieRequest("apptoken", s.auth.RequireApplicationToken, 200) + s.assertCookieRequest("apptoken", s.auth.RequireClient, 401) + s.assertCookieRequest("apptoken", s.auth.RequireAdmin, 401) + + // clienttoken + s.assertCookieRequest("clienttoken", s.auth.RequireApplicationToken, 401) + s.assertCookieRequest("clienttoken", s.auth.RequireClient, 200) + s.assertCookieRequest("clienttoken", s.auth.RequireAdmin, 403) + s.assertCookieRequest("clienttoken_admin", s.auth.RequireClient, 200) + s.assertCookieRequest("clienttoken_admin", s.auth.RequireAdmin, 200) +} + +func (s *AuthenticationSuite) assertCookieRequest(token string, f fMiddleware, code int) (ctx *gin.Context) { + recorder := httptest.NewRecorder() + ctx, _ = gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.AddCookie(&http.Cookie{Name: cookieName, Value: token}) + f(ctx) + assert.Equal(s.T(), code, recorder.Code) + return ctx +} + func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) { recorder := httptest.NewRecorder() ctx, _ = gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", "/", nil) ctx.Request.Header.Set(key, value) - f()(ctx) + f(ctx) assert.Equal(s.T(), code, recorder.Code) return ctx } -type fMiddleware func() gin.HandlerFunc +type fMiddleware gin.HandlerFunc diff --git a/auth/cookie.go b/auth/cookie.go new file mode 100644 index 00000000..ed68ca40 --- /dev/null +++ b/auth/cookie.go @@ -0,0 +1,22 @@ +package auth + +import ( + "net/http" +) + +// CookieMaxAge is the lifetime of the session cookie in seconds (7 days). +const CookieMaxAge = 7 * 24 * 60 * 60 + +const CookieName = "gotify-client-token" + +func SetCookie(w http.ResponseWriter, token string, maxAge int, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + MaxAge: maxAge, + Secure: secure, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} diff --git a/auth/util.go b/auth/util.go index 156648ec..6f380e12 100644 --- a/auth/util.go +++ b/auth/util.go @@ -39,3 +39,7 @@ func TryGetUserID(ctx *gin.Context) *uint { func GetTokenID(ctx *gin.Context) string { return ctx.MustGet("tokenid").(string) } + +func TryGetTokenID(ctx *gin.Context) string { + return ctx.GetString("tokenid") +} diff --git a/config.example.yml b/config.example.yml index 393e9ea4..b1bdfed0 100644 --- a/config.example.yml +++ b/config.example.yml @@ -3,13 +3,13 @@ server: keepaliveperiodseconds: 0 # 0 = use Go default (15s); -1 = disable keepalive; set the interval in which keepalive packets will be sent. Only change this value if you know what you are doing. - listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". + listenaddr: '' # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". port: 80 # the port the HTTP server will listen on ssl: enabled: false # if https should be enabled redirecttohttps: true # redirect to https if site is accessed by http - listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". + listenaddr: '' # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". port: 443 # the https port certfile: # the cert file (leave empty when using letsencrypt) certkey: # the cert key (leave empty when using letsencrypt) @@ -18,44 +18,51 @@ server: accepttos: false # if you accept the tos from letsencrypt cache: data/certs # the directory of the cache from letsencrypt directoryurl: # override the directory url of the ACME server - # Let's Encrypt highly recommend testing against their staging environment before using their production environment. - # Staging server has high rate limits for testing and debugging, issued certificates are not valid - # example: https://acme-staging-v02.api.letsencrypt.org/directory + # Let's Encrypt highly recommend testing against their staging environment before using their production environment. + # Staging server has high rate limits for testing and debugging, issued certificates are not valid + # example: https://acme-staging-v02.api.letsencrypt.org/directory hosts: # the hosts for which letsencrypt should request certificates -# - mydomain.tld -# - myotherdomain.tld - +# - mydomain.tld +# - myotherdomain.tld responseheaders: # response headers are added to every response (default: none) -# X-Custom-Header: "custom value" -# +# X-Custom-Header: "custom value" + trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets) # - 127.0.0.1/32 # - ::1 + securecookie: false # If the secure flag should be set on cookies. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#secure cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers. alloworigins: -# - ".+.example.com" -# - "otherdomain.com" +# - '.+.example.com' +# - 'otherdomain.com' allowmethods: -# - "GET" -# - "POST" +# - "GET" +# - "POST" allowheaders: -# - "Authorization" -# - "content-type" +# - "Authorization" +# - "content-type" stream: pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing. allowedorigins: # allowed origins for websocket connections (same origin is always allowed) -# - ".+.example.com" -# - "otherdomain.com" +# - ".+.example.com" +# - "otherdomain.com" +oidc: + enabled: false # Enable OpenID Connect login, allowing users to authenticate via an external identity provider (e.g. Keycloak, Authelia, Google). + issuer: # The OIDC issuer URL. This is the base URL of your identity provider, used to discover endpoints. Example: "https://auth.example.com/realms/myrealm" + clientid: # The client ID registered with your identity provider for this application. + clientsecret: # The client secret for the registered client. + redirecturl: http://gotify.example.org/auth/oidc/callback # The callback URL that the identity provider redirects to after authentication. Must match exactly what is configured in your identity provider. + autoregister: true # If true, automatically create a new user on first OIDC login. If false, only existing users can log in via OIDC. + usernameclaim: preferred_username # The OIDC claim used to determine the username. Common values: "preferred_username" or "email". database: # for database see (configure database section) dialect: sqlite3 connection: data/gotify.db - -defaultuser: # on database creation, gotify creates an admin user +defaultuser: # on database creation, gotify creates an admin user (these values will only be used for the first start, if you want to edit the user after the first start use the WebUI) name: admin # the username of the default user pass: admin # the password of the default user passstrength: 10 # the bcrypt password strength (higher = better but also slower) uploadedimagesdir: data/images # the directory for storing uploaded images -pluginsdir: data/plugins # the directory where plugin resides +pluginsdir: data/plugins # the directory where plugin resides (leave empty to disable plugins) registration: false # enable registrations diff --git a/config/config.go b/config/config.go index 32bd788d..c299d8ee 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,7 @@ type Configuration struct { } TrustedProxies []string + SecureCookie bool `default:"false"` } Database struct { Dialect string `default:"sqlite3"` @@ -55,6 +56,16 @@ type Configuration struct { UploadedImagesDir string `default:"data/images"` PluginsDir string `default:"data/plugins"` Registration bool `default:"false"` + OIDC struct { + Enabled bool `default:"false"` + Issuer string `default:""` + ClientID string `default:""` + ClientSecret string `default:""` + UsernameClaim string `default:"preferred_username"` + RedirectURL string `default:""` + AutoRegister bool `default:"true"` + Scopes []string + } } func configFiles() []string { diff --git a/docs/package.go b/docs/package.go index 9597adfa..8fdda71f 100644 --- a/docs/package.go +++ b/docs/package.go @@ -17,7 +17,7 @@ // // Schemes: http, https // Host: localhost -// Version: 2.0.2 +// Version: 2.1.0 // License: MIT https://github.com/gotify/server/blob/master/LICENSE // // Consumes: @@ -35,21 +35,21 @@ // type: apiKey // name: token // in: query -// appTokenHeader: +// appTokenHeader: // type: apiKey // name: X-Gotify-Key // in: header -// clientTokenHeader: +// clientTokenHeader: // type: apiKey // name: X-Gotify-Key // in: header -// appTokenAuthorizationHeader: +// appTokenAuthorizationHeader: // type: apiKey // name: Authorization // in: header // description: >- // Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`. -// clientTokenAuthorizationHeader: +// clientTokenAuthorizationHeader: // type: apiKey // name: Authorization // in: header diff --git a/docs/spec.json b/docs/spec.json index 1e26969f..090204f2 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -17,7 +17,7 @@ "name": "MIT", "url": "https://github.com/gotify/server/blob/master/LICENSE" }, - "version": "2.0.2" + "version": "2.1.0" }, "host": "localhost", "paths": { @@ -587,6 +587,153 @@ } } }, + "/auth/oidc/callback": { + "get": { + "description": "Exchanges the authorization code for tokens, resolves the user,\ncreates a gotify client, sets a session cookie, and redirects to the UI.", + "tags": [ + "oidc" + ], + "summary": "Handle the OIDC provider callback (browser).", + "operationId": "oidcCallback", + "parameters": [ + { + "type": "string", + "description": "the authorization code from the OIDC provider", + "name": "code", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "the state parameter for CSRF protection", + "name": "state", + "in": "query", + "required": true + } + ], + "responses": { + "307": { + "description": "Redirect to UI" + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/auth/oidc/external/authorize": { + "post": { + "description": "The app generates a PKCE code_verifier and code_challenge, then calls this\nendpoint. The server forwards the code_challenge to the OIDC provider and\nreturns the authorization URL for the app to open in a browser.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oidc" + ], + "summary": "Initiate the OIDC authorization flow for a native app.", + "operationId": "externalAuthorize", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OIDCExternalAuthorizeRequest" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/OIDCExternalAuthorizeResponse" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/auth/oidc/external/token": { + "post": { + "description": "After the user authenticates with the OIDC provider and the app receives\nthe authorization code via redirect, the app calls this endpoint with the\ncode and PKCE code_verifier. The server exchanges the code with the OIDC\nprovider and returns a gotify client token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oidc" + ], + "summary": "Exchange an authorization code for a gotify client token.", + "operationId": "externalToken", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OIDCExternalTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/OIDCExternalTokenResponse" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/auth/oidc/login": { + "get": { + "description": "Redirects the user to the OIDC provider's authorization endpoint.\nAfter authentication, the provider redirects back to the callback endpoint.", + "tags": [ + "oidc" + ], + "summary": "Start the OIDC login flow (browser).", + "operationId": "oidcLogin", + "parameters": [ + { + "type": "string", + "description": "the client name to create after login", + "name": "name", + "in": "query", + "required": true + } + ], + "responses": { + "302": { + "description": "Redirect to OIDC provider" + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/client": { "get": { "security": [ @@ -958,13 +1105,33 @@ } } }, + "/gotifyinfo": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Get gotify information.", + "operationId": "getInfo", + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/GotifyInfo" + } + } + } + } + }, "/health": { "get": { "produces": [ "application/json" ], "tags": [ - "health" + "info" ], "summary": "Get health information.", "operationId": "getHealth", @@ -2034,7 +2201,7 @@ "application/json" ], "tags": [ - "version" + "info" ], "summary": "Get version information.", "operationId": "getVersion", @@ -2287,6 +2454,36 @@ }, "x-go-package": "github.com/gotify/server/v2/model" }, + "GotifyInfo": { + "description": "GotifyInfo Model", + "type": "object", + "required": [ + "version", + "register", + "oidc" + ], + "properties": { + "oidc": { + "description": "If oidc is enabled.", + "type": "boolean", + "x-go-name": "Oidc", + "example": true + }, + "register": { + "description": "If registration is enabled.", + "type": "boolean", + "x-go-name": "Register", + "example": true + }, + "version": { + "description": "The current version.", + "type": "string", + "x-go-name": "Version", + "example": "5.2.6" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, "Health": { "description": "Health represents how healthy the application is.", "type": "object", @@ -2383,6 +2580,112 @@ "x-go-name": "MessageExternal", "x-go-package": "github.com/gotify/server/v2/model" }, + "OIDCExternalAuthorizeRequest": { + "description": "Used to initiate the OIDC authorization flow for an external client.", + "type": "object", + "title": "OIDCExternalAuthorizeRequest Model", + "required": [ + "code_challenge", + "redirect_uri", + "name" + ], + "properties": { + "code_challenge": { + "description": "The PKCE code challenge (S256).", + "type": "string", + "x-go-name": "CodeChallenge", + "example": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + }, + "name": { + "description": "The client name to display in gotify.", + "type": "string", + "x-go-name": "Name", + "example": "Android Phone" + }, + "redirect_uri": { + "description": "The app's redirect URI.", + "type": "string", + "x-go-name": "RedirectURI", + "example": "gotify://oidc/callback" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, + "OIDCExternalAuthorizeResponse": { + "description": "Returned after initiating the OIDC authorization flow.", + "type": "object", + "title": "OIDCExternalAuthorizeResponse Model", + "required": [ + "authorize_url", + "state" + ], + "properties": { + "authorize_url": { + "description": "The URL to open in the browser to authenticate with the OIDC provider.", + "type": "string", + "x-go-name": "AuthorizeURL", + "example": "https://auth.example.com/authorize?client_id=gotify\u0026..." + }, + "state": { + "description": "The state parameter to send back with the token exchange request.", + "type": "string", + "x-go-name": "State", + "example": "Android Phone:a1b2c3d4e5f6" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, + "OIDCExternalTokenRequest": { + "description": "Used to exchange an authorization code for a gotify client token.", + "type": "object", + "title": "OIDCExternalTokenRequest Model", + "required": [ + "code", + "state", + "code_verifier" + ], + "properties": { + "code": { + "description": "The authorization code from the OIDC provider.", + "type": "string", + "x-go-name": "Code" + }, + "code_verifier": { + "description": "The PKCE code verifier.", + "type": "string", + "x-go-name": "CodeVerifier", + "example": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + }, + "state": { + "description": "The state from the authorize response.", + "type": "string", + "x-go-name": "State", + "example": "Android Phone:a1b2c3d4e5f6" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, + "OIDCExternalTokenResponse": { + "description": "Returned after a successful token exchange.", + "type": "object", + "title": "OIDCExternalTokenResponse Model", + "required": [ + "token", + "user" + ], + "properties": { + "token": { + "description": "The gotify client token for API authentication.", + "type": "string", + "x-go-name": "Token", + "example": "CWH0wZ5r0Mbac.r" + }, + "user": { + "$ref": "#/definitions/User" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, "PagedMessages": { "description": "Wrapper for the paging and the messages.", "type": "object", diff --git a/go.mod b/go.mod index 0a9aa518..c519e2cc 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/robfig/cron v1.2.0 github.com/stretchr/testify v1.11.1 + github.com/zitadel/oidc/v3 v3.45.5 golang.org/x/crypto v0.48.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 @@ -28,15 +29,21 @@ require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect @@ -49,15 +56,24 @@ require ( github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/zitadel/logging v0.7.0 // indirect + github.com/zitadel/schema v1.3.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 2d258e77..fbb19cba 100644 --- a/go.sum +++ b/go.sum @@ -3,12 +3,16 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,6 +32,15 @@ github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fq github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -46,6 +59,12 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms= @@ -62,6 +81,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -90,6 +111,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -100,8 +125,12 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -119,8 +148,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= +github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= +github.com/zitadel/oidc/v3 v3.45.5 h1:CubfcXQiqtysk+FZyIcvj1+1ayvdSV89v5xWu5asrDQ= +github.com/zitadel/oidc/v3 v3.45.5/go.mod h1:MKHUazeiNX/jxRc6HD/Dv9qhL/wNuzrJAadBEGXiBeE= +github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= +github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= @@ -130,6 +173,8 @@ golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVo golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -147,6 +192,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/model/gotifyinfo.go b/model/gotifyinfo.go new file mode 100644 index 00000000..c2db0bd2 --- /dev/null +++ b/model/gotifyinfo.go @@ -0,0 +1,22 @@ +package model + +// GotifyInfo Model +// +// swagger:model GotifyInfo +type GotifyInfo struct { + // The current version. + // + // required: true + // example: 5.2.6 + Version string `json:"version"` + // If registration is enabled. + // + // required: true + // example: true + Register bool `json:"register"` + // If oidc is enabled. + // + // required: true + // example: true + Oidc bool `json:"oidc"` +} diff --git a/model/oidc.go b/model/oidc.go new file mode 100644 index 00000000..45ee1358 --- /dev/null +++ b/model/oidc.go @@ -0,0 +1,81 @@ +package model + +// OIDCExternalAuthorizeRequest Model +// +// Used to initiate the OIDC authorization flow for an external client. +// +// swagger:model OIDCExternalAuthorizeRequest +type OIDCExternalAuthorizeRequest struct { + // The PKCE code challenge (S256). + // + // required: true + // example: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + CodeChallenge string `json:"code_challenge" binding:"required"` + // The app's redirect URI. + // + // required: true + // example: gotify://oidc/callback + RedirectURI string `json:"redirect_uri" binding:"required"` + // The client name to display in gotify. + // + // required: true + // example: Android Phone + Name string `json:"name" binding:"required"` +} + +// OIDCExternalAuthorizeResponse Model +// +// Returned after initiating the OIDC authorization flow. +// +// swagger:model OIDCExternalAuthorizeResponse +type OIDCExternalAuthorizeResponse struct { + // The URL to open in the browser to authenticate with the OIDC provider. + // + // required: true + // example: https://auth.example.com/authorize?client_id=gotify&... + AuthorizeURL string `json:"authorize_url"` + // The state parameter to send back with the token exchange request. + // + // required: true + // example: Android Phone:a1b2c3d4e5f6 + State string `json:"state"` +} + +// OIDCExternalTokenRequest Model +// +// Used to exchange an authorization code for a gotify client token. +// +// swagger:model OIDCExternalTokenRequest +type OIDCExternalTokenRequest struct { + // The authorization code from the OIDC provider. + // + // required: true + Code string `json:"code" binding:"required"` + // The state from the authorize response. + // + // required: true + // example: Android Phone:a1b2c3d4e5f6 + State string `json:"state" binding:"required"` + // The PKCE code verifier. + // + // required: true + // example: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk + CodeVerifier string `json:"code_verifier" binding:"required"` +} + +// OIDCExternalTokenResponse Model +// +// Returned after a successful token exchange. +// +// swagger:model OIDCExternalTokenResponse +type OIDCExternalTokenResponse struct { + // The gotify client token for API authentication. + // + // required: true + // example: CWH0wZ5r0Mbac.r + Token string `json:"token"` + // The authenticated user. + // + // required: true + User *UserExternal `json:"user"` +} diff --git a/router/router.go b/router/router.go index 8b191373..f1be10ba 100644 --- a/router/router.go +++ b/router/router.go @@ -74,7 +74,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co db.UpdateClientTokensLastUsed(connectedTokens, &now) } }() - authentication := auth.Auth{DB: db} + authentication := auth.Auth{DB: db, SecureCookie: conf.Server.SecureCookie} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} healthHandler := api.HealthAPI{DB: db} clientHandler := api.ClientAPI{ @@ -86,6 +86,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co DB: db, ImageDir: conf.UploadedImagesDir, } + sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient, SecureCookie: conf.Server.SecureCookie} userChangeNotifier := new(api.UserChangeNotifier) userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration} @@ -103,7 +104,16 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser) userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID) - ui.Register(g, *vInfo, conf.Registration) + ui.Register(g, *vInfo, conf.Registration, conf.OIDC.Enabled) + + if conf.OIDC.Enabled { + oidcHandler := api.NewOIDC(conf, db, userChangeNotifier) + oidcGroup := g.Group("/auth/oidc") + oidcGroup.GET("/login", oidcHandler.LoginHandler()) + oidcGroup.GET("/callback", oidcHandler.CallbackHandler()) + oidcGroup.POST("/external/authorize", oidcHandler.ExternalAuthorizeHandler) + oidcGroup.POST("/external/token", oidcHandler.ExternalTokenHandler) + } g.Match([]string{"GET", "HEAD"}, "/health", healthHandler.Health) g.GET("/swagger", docs.Serve) @@ -120,8 +130,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co g.Use(cors.New(auth.CorsConfig(conf))) { - g.GET("/plugin", authentication.RequireClient(), pluginHandler.GetPlugins) - pluginRoute := g.Group("/plugin/", authentication.RequireClient()) + g.GET("/plugin", authentication.RequireClient, pluginHandler.GetPlugins) + pluginRoute := g.Group("/plugin/", authentication.RequireClient) { pluginRoute.GET("/:id/config", pluginHandler.GetConfig) pluginRoute.POST("/:id/config", pluginHandler.UpdateConfig) @@ -131,11 +141,13 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co } } - g.Group("/user").Use(authentication.Optional()).POST("", userHandler.CreateUser) + g.Group("/user").Use(authentication.Optional).POST("", userHandler.CreateUser) + + g.POST("/auth/local/login", sessionHandler.Login) g.OPTIONS("/*any") - // swagger:operation GET /version version getVersion + // swagger:operation GET /version info getVersion // // Get version information. // @@ -150,11 +162,26 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co ctx.JSON(200, vInfo) }) - g.Group("/").Use(authentication.RequireApplicationToken()).POST("/message", messageHandler.CreateMessage) + // swagger:operation GET /gotifyinfo info getInfo + // + // Get gotify information. + // + // --- + // produces: [application/json] + // responses: + // 200: + // description: Ok + // schema: + // $ref: "#/definitions/GotifyInfo" + g.GET("gotifyinfo", func(ctx *gin.Context) { + ctx.JSON(200, &model.GotifyInfo{Version: vInfo.Version, Oidc: conf.OIDC.Enabled, Register: conf.Registration}) + }) + + g.Group("/").Use(authentication.RequireApplicationToken).POST("/message", messageHandler.CreateMessage) clientAuth := g.Group("") { - clientAuth.Use(authentication.RequireClient()) + clientAuth.Use(authentication.RequireClient) app := clientAuth.Group("/application") { app.GET("", applicationHandler.GetApplications) @@ -202,11 +229,13 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co clientAuth.GET("current/user", userHandler.GetCurrentUser) clientAuth.POST("current/user/password", userHandler.ChangePassword) + + clientAuth.POST("/auth/local/logout", sessionHandler.Logout) } authAdmin := g.Group("/user") { - authAdmin.Use(authentication.RequireAdmin()) + authAdmin.Use(authentication.RequireAdmin) authAdmin.GET("", userHandler.GetUsers) diff --git a/test/oidc/.gitignore b/test/oidc/.gitignore new file mode 100644 index 00000000..1adda8a2 --- /dev/null +++ b/test/oidc/.gitignore @@ -0,0 +1,4 @@ +/dex/config/dex.db +/authelia/config/* +!/authelia/config/configuration.yml +!/authelia/config/users_database.yml diff --git a/test/oidc/README.md b/test/oidc/README.md new file mode 100644 index 00000000..b1377324 --- /dev/null +++ b/test/oidc/README.md @@ -0,0 +1,44 @@ +# OIDC Testing + +## Dex + +Check config in ./dex/config/dex.conf and do a `docker-compose up -d`. + +Use this gotify config. +``` +oidc: + enabled: true + issuer: http://127.0.0.1:5556/dex + clientid: gotify + clientsecret: secret + redirecturl: http://127.0.0.1:8080/auth/oidc/callback +``` + +When testing external apps like gotify/android change every occurence of +127.0.0.1 in ./dex/config/dex.conf and in the gotify config above to an IP that's +routed in your local network like 192.168.178.2. + +## Authelia + +Authelia requires SSL to work, so you'll have to create a valid certificate. This has to be executed in the directory this README resides. + +``` +openssl req -x509 -newkey rsa:4096 -nodes -keyout ./authelia/config/key -out ./authelia/config/cert -days 365 -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1" +``` + +Check config in ./authelia/config/configuration.yml and do a `docker-compose up -d`. + +Use this gotify config. +``` +oidc: + enabled: true + issuer: https://127.0.0.1:9091 + clientid: gotify + clientsecret: secret + redirecturl: http://127.0.0.1:8080/auth/oidc/callback +``` + +When testing external apps like gotify/android change every occurence of +127.0.0.1 in ./authelia/config/configuration.yml and in the gotify config above +to an IP that's routed in your local network like 192.168.178.2. Also recreate +the certificate with the adjusted IP. diff --git a/test/oidc/authelia/config/configuration.yml b/test/oidc/authelia/config/configuration.yml new file mode 100644 index 00000000..d45a8f0a --- /dev/null +++ b/test/oidc/authelia/config/configuration.yml @@ -0,0 +1,125 @@ +# yamllint disable rule:comments-indentation +--- +theme: 'auto' + +server: + tls: + key: '/config/key' + certificate: '/config/cert' +identity_validation: + reset_password: + jwt_secret: 'a_very_important_secret' + +authentication_backend: + file: + path: '/config/users_database.yml' + password: + algorithm: 'bcrypt' + bcrypt: + variant: 'standard' + cost: 12 +access_control: + default_policy: 'one_factor' + +session: + secret: 'a_very_important_secret' + cookies: + - name: 'authelia_session' + domain: '127.0.0.1' + authelia_url: 'https://127.0.0.1:9091' + +storage: + encryption_key: 'a_very_important_secret' + local: + path: '/config/db.sqlite3' + +notifier: + filesystem: + filename: '/config/notification.txt' + +identity_providers: + oidc: + jwks: + - algorithm: 'RS256' + use: 'sig' + key: | + -----BEGIN PRIVATE KEY----- + MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCPrHPExpLkhNYd + 5piRQzhWqMDAgqOjXhZHyYjfJYcanO76PimJe10c6ES9ULP1Iu0VltvE8ubI0Jek + mF1nmQfYbw8efnP2zpodrPpMR6EqXMiaNFj2wd6Y0Mu0xqjvoDPHkB60kC2QqjiD + 1bFP280xXSl9yUeJK6PMM0mpWUJzDiW327OVTGs3AV0BU970KHs6XL6fZ3MNEo2V + FGLRH+5g68THb1LxpKKaU+nIv/IRJBKZH80NNyzD1+TJQIkqAg2O9G3ozcgTP8zu + yyeemn9snB+09SaL5/GnBOztYZ7jJnAbUrotx6BMSzrkuvfWrPB7G1O1CMMp4xUj + ylM6/ciXIBpPLMPPVU9SfD4DyDH7XJ/S5NdvpJGNQcRSVG5JbdX4VPso2eze3Xa7 + /9BlAOh00WS/ZUoBjT0js1p1rjsD8U/bjMqG49Ids5pD1DElH+4uZNQ1xFq5GVbt + ynd7GK+DF/XF+Vb56vYDVs0N/I6END8eeYCnUbCeWKwpKVN3XyX4wZ8Rp7mQiYna + i1M4MENihS4HWvnfuswLkF5nLEpy0u5SV7Od3Mob8DPIZDeKt38WkTWTdDa3JeEv + QU8Hv7r6hdIefMilJtZBS+QuJwzFtt+JeGbn5Xid7k3lTAGi7/uRXbE5H1YtTlQE + S3XZAQ3tCEoclrP9N7E+Pm2YC+Jr5QIDAQABAoICABeiMg757TrrAP+9KXanvJJA + wyhHtRxQA1E+vSWb2jwN+Z+vbwy+/sOdD4Wmy1t1KdPF05PzsvPwoClCqQa8HRbE + uhN1kKTWOnLMPAYlOEUsKxF2r/WzUWcI3aF4llyImUvoEKz6FIy5+37wPXEaAohu + vz8CR6KwS4rxGtphJPWhK6IxYTqbbf2H22E3BzNZn1+r1u2IyluppCGUT2cAHinS + TrXRwa6fOuIxEIFl1a9tJCQNH6FfZJ04m9lhJM8EtG9CFPxZMWK9OXxEbdmAp5pZ + mjudogAcoNstC75GsyjBb2qHMroKHvu92ku61774Brzxc1URwmzW/mi7RPKswXyf + arOpk8l2rJZoJeQUnyJr+sab9SbW33pc6WTDEPMtllc26G6bjQggCYElPk035ed9 + eAJUXqiH/O2olS/jDwv/P9VqyDIAn75SpMVs0UvJKA6RSkOP5R/uF2gcOdYinluH + jUkj5Wuqz1ewRW6RB2O6yocS8d5momnfQ0kvPGOeNLToQ4B7zRooO6rZBWziMwxr + Vi2/8BX7SS7NQhz+mt7XwxsPkOcnx7+FL2tI+/FXwOikbFxieCI1IOaqNXQ1870i + //iWVALHRRcF69jPODlqHcnio4UxxuddkJkSwJWSgoGECqTav0oQ6nr5Bldo53dD + JlLfoGGSWHk64rhwuKDnAoIBAQDKcfpc0HFwFLz0PacY82XitaA9/cLSL0Axnu0p + 5iRIHU9MUitzYaaMLV1XMYZr6ItL0RnIHfqRaA5wloQTlibPbCCELQmiNhDp9Kv1 + h4TeICynJ3z9iPfsIJW3t+kovg7j2yiWhFZSwD9ktZBTrG6he8deE2y0Xw2apxRU + NrlIeE33Gjnqo5SijcZ/VL89oJQr4lys93O0IqgETix2+RA6P2ouraPCvakL2flm + V3T4ovki8qayxSirFJ4ew2E3hapukGAqZEodh+Rd3QHyaAmjixGOEgq0fmKDUvgH + zCVGwkFHV0CUQrbK2blYQk55BjLrU7NAl0DppXjxLH+qfc4zAoIBAQC1rlZZZaVu + 08JuMZVR4TMOSmevJ8hLx+Upm7JOz8JNI+SGZQ/4hcoq2YTfdu26RFo86Yf+M2cn + ZXuGcmMJIGRl0hhFl/8/1akRDPLXP5hWtJe3UIqDuA2WhTaonT9oOENAiijnnLuY + za8nIHuYPOKSvryTsU1cxJf7FJbSG0kcVZCNREss6A8hCZB/idTeKwN8CNR1hS82 + zdBkFoo38G3ZYctHw5+uqzwrafT6BeG5WDqbfJkTpFWcvgPjPwWv1KNviDusRT68 + UqRuNAlO9z0tdU9VjK8v6BMPsv7CZQAEAVRlbHvaQW3LMPdKakE7Ud8qu8fWSkzw + nS5cKAv2XZWHAoIBAQCZP9zho90rlldPoNg8eAxZqVorc0ympaQ3q/ImtJQkjyN3 + SACicHqORM0S82epijjgZOLabW8/4YCE1DwZQ6IPhO+8fwd65ui44kHGNRdsuvhy + dN8WYjgjZKtRjwQOlolZDY9VGcrrC6Mxjow5+x8oWTYbziKNDCOVPgOSmHZ8GK4U + b6MGL1yWDTMFMtcuRL/F1K6JNS0+YLnFwJPCYFpbbaPowANmqQIt+YzlXzEqAt0M + CpoMXFmj4JCuAwM175aL6fkSPico4bULJQGTShR53A2m+Ztm9QGIHieqZ2yUevrF + kZROZ45OUrEO0errjLjBEfRw4c7+0AeUsjXWjzOnAoIBAQCpuMZz1xAXm30sAefz + SMSwWfPIXgqwOHotR4ToOQ/Tjm9C2ZB04088fl2xgGGOu6Hs+2COqSh5VkVyENPR + x8/iisUf5mGOGaRKCGWnjYJbpXOBzZzIdh1DewjXtaZxTvYMicSyselSUvuIOsEb + M+2ZltOFyYFy4zjzVoWam+DNtmVGgwETX2oau9ugOXuBXH9x1LHdY2D6+oPtrFzM + 6y9Dfycu0GIRA2g/SkmPdAUtZ23AqUI7Zi6QMbZiCRLf8m4HmCXexgVYWn+/b58u + hKtDFy7YxYc24r9D0DxMD5xXIYLdCN4ewza1NfYeL2rm5pHrUubZmimMMdoIP2UF + buFrAoIBAHMP3Qzd3VNQo2cDwrFZNtj1BuzDdr7t1N02M3IU5ivqxp/pZrPKwgUr + rYPzHH3jKgi5YTSN/+Gy+1DHtED05KwwYKGP5UL0rXDzWAl/6G8HeRB4ag0K9q8A + Nki7JA0pA7D7Z9/w+j4VINrXt/65ZX2MY1ZKmPEjrHWQzLZzBpZ8BWbJlBjMjNBw + tWZ1BxdajoSVjG2h6okWI4yvV1VxMKvKei9HNjLKqNVn55qx4xKOxS+hcdHhmjQL + 9sa0D55tkspi3ZVzMZ3XrogElxMhSEpM5ivQoy9WvKk/R9EEAzFKIdY0LC3Zww2Z + 1+nG9oQcrdep1QE+8byjndJp/i6IBRU= + -----END PRIVATE KEY----- + + enable_client_debug_messages: true + clients: + - client_id: 'gotify' + client_name: 'gotify' + client_secret: '$pbkdf2-sha512$310000$PeubGcDkDhxS.WUNH6h04g$SQKuwJmUkPtQVWMz9nJoEUdvkYjRdkWEQO73zLiK4JRLapTWD9DYAHIt25h/FT1Nv059YSiMUpRUBbheSVJBAQ' # secret + public: false + authorization_policy: 'one_factor' + require_pkce: true + pkce_challenge_method: 'S256' + consent_mode: implicit + redirect_uris: + - 'http://127.0.0.1:8080/auth/oidc/callback' + - 'http://127.0.0.1:5173/auth/oidc/callback' + - 'http://localhost:8080/auth/oidc/callback' + - 'http://localhost:5173/auth/oidc/callback' + - 'gotify://oidc/callback' + scopes: + - 'openid' + - 'profile' + - 'email' + response_types: + - 'code' + grant_types: + - 'authorization_code' + access_token_signed_response_alg: 'none' + userinfo_signed_response_alg: 'none' + token_endpoint_auth_method: 'client_secret_basic' diff --git a/test/oidc/authelia/config/users_database.yml b/test/oidc/authelia/config/users_database.yml new file mode 100644 index 00000000..fb545ec2 --- /dev/null +++ b/test/oidc/authelia/config/users_database.yml @@ -0,0 +1,5 @@ +users: + user: + displayname: "user" + password: "$2a$10$JoPsdyz7c9Q1bqhw1.bHrefdNlOWY0/22VQZh33X9vDEl3Du1utqe" # password + email: user@gotify.net diff --git a/test/oidc/authelia/docker-compose.yml b/test/oidc/authelia/docker-compose.yml new file mode 100644 index 00000000..3e06ab36 --- /dev/null +++ b/test/oidc/authelia/docker-compose.yml @@ -0,0 +1,13 @@ +services: + authelia: + container_name: 'authelia' + image: 'docker.io/authelia/authelia:latest' + restart: 'unless-stopped' + environment: + - PUID=1000 + - PGID=1000 + ports: + - 9091:9091 + volumes: + - './config:/config' + - './secrets:/secrets' diff --git a/test/oidc/dex/config/dex.conf b/test/oidc/dex/config/dex.conf new file mode 100644 index 00000000..a0beef58 --- /dev/null +++ b/test/oidc/dex/config/dex.conf @@ -0,0 +1,35 @@ +issuer: http://127.0.0.1:5556/dex + +storage: + type: sqlite3 + config: + file: /config/dex.db +web: + http: 0.0.0.0:5556 + +staticClients: +- id: gotify + redirectURIs: + - 'http://localhost:8080/auth/oidc/callback' + - 'http://localhost:5173/auth/oidc/callback' + - 'http://127.0.0.1:8080/auth/oidc/callback' + - 'http://127.0.0.1:5173/auth/oidc/callback' + - 'gotify://oidc/callback' + name: 'Gotify' + secret: secret + +enablePasswordDB: true + +staticPasswords: +- email: "user@gotify.net" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" # password + username: "user" + name: "USER" + emailVerified: true + preferredUsername: "user" + userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" + +signer: + type: local + config: + keysRotationPeriod: "6h" diff --git a/test/oidc/dex/docker-compose.yml b/test/oidc/dex/docker-compose.yml new file mode 100644 index 00000000..5c584b83 --- /dev/null +++ b/test/oidc/dex/docker-compose.yml @@ -0,0 +1,9 @@ +services: + dex: + image: ghcr.io/dexidp/dex:latest + command: dex serve /config/dex.conf + user: '1000' + ports: + - 5556:5556 + volumes: + - ./config/:/config diff --git a/ui/serve.go b/ui/serve.go index 07664937..45e46f44 100644 --- a/ui/serve.go +++ b/ui/serve.go @@ -18,11 +18,12 @@ var box embed.FS type uiConfig struct { Register bool `json:"register"` Version model.VersionInfo `json:"version"` + OIDC bool `json:"oidc"` } // Register registers the ui on the root path. -func Register(r *gin.Engine, version model.VersionInfo, register bool) { - uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register}) +func Register(r *gin.Engine, version model.VersionInfo, register, oidcEnabled bool) { + uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register, OIDC: oidcEnabled}) if err != nil { panic(err) } diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts index 2510de91..38b427c9 100644 --- a/ui/src/CurrentUser.ts +++ b/ui/src/CurrentUser.ts @@ -3,12 +3,9 @@ import * as config from './config'; import {detect} from 'detect-browser'; import {SnackReporter} from './snack/SnackManager'; import {observable, runInAction, action} from 'mobx'; -import {IClient, IUser} from './types'; - -const tokenKey = 'gotify-login-key'; +import {IUser} from './types'; export class CurrentUser { - private tokenCache: string | null = null; private reconnectTimeoutId: number | null = null; private reconnectTime = 7500; @observable accessor loggedIn = false; @@ -19,25 +16,6 @@ export class CurrentUser { public constructor(private readonly snack: SnackReporter) {} - public token = (): string => { - if (this.tokenCache !== null) { - return this.tokenCache; - } - - const localStorageToken = window.localStorage.getItem(tokenKey); - if (localStorageToken) { - this.tokenCache = localStorageToken; - return localStorageToken; - } - - return ''; - }; - - private readonly setToken = (token: string) => { - this.tokenCache = token; - window.localStorage.setItem(tokenKey, token); - }; - public register = async (name: string, pass: string): Promise => axios .create() @@ -60,30 +38,35 @@ export class CurrentUser { return false; }); + public createClientName = (): string => { + const browser = detect(); + return (browser && browser.name + ' ' + browser.version) || 'unknown browser'; + }; + public login = async (username: string, password: string) => { runInAction(() => { this.loggedIn = false; this.authenticating = true; }); - const browser = detect(); - const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser'; + const name = this.createClientName(); axios .create() .request({ - url: config.get('url') + 'client', + url: config.get('url') + 'auth/local/login', method: 'POST', data: {name}, headers: {Authorization: 'Basic ' + btoa(username + ':' + password)}, }) - .then((resp: AxiosResponse) => { - this.snack(`A client named '${name}' was created for your session.`); - this.setToken(resp.data.token); - this.tryAuthenticate().catch(() => { - console.log( - 'create client succeeded, but authenticated with given token failed' - ); - }); - }) + .then( + action((resp: AxiosResponse) => { + this.snack(`A client named '${name}' was created for your session.`); + this.user = resp.data; + this.loggedIn = true; + this.authenticating = false; + this.connectionErrorMessage = null; + this.reconnectTime = 7500; + }) + ) .catch( action(() => { this.authenticating = false; @@ -93,16 +76,9 @@ export class CurrentUser { }; public tryAuthenticate = async (): Promise> => { - if (this.token() === '') { - runInAction(() => { - this.authenticating = false; - }); - return Promise.reject(); - } - return axios .create() - .get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}}) + .get(config.get('url') + 'current/user') .then( action((passThrough) => { this.user = passThrough.data; @@ -139,19 +115,14 @@ export class CurrentUser { }; public logout = async () => { - await axios - .get(config.get('url') + 'client') - .then((resp: AxiosResponse) => { - resp.data - .filter((client) => client.token === this.tokenCache) - .forEach((client) => axios.delete(config.get('url') + 'client/' + client.id)); - }) - .catch(() => Promise.resolve()); - window.localStorage.removeItem(tokenKey); - this.tokenCache = null; - runInAction(() => { - this.loggedIn = false; - }); + if (this.loggedIn) { + runInAction(() => { + this.loggedIn = false; + }); + await axios + .post(config.get('url') + 'auth/local/logout') + .catch(() => Promise.resolve()); + } }; public changePassword = (pass: string) => { diff --git a/ui/src/apiAuth.ts b/ui/src/apiAuth.ts index 183c8d66..7df7ad5d 100644 --- a/ui/src/apiAuth.ts +++ b/ui/src/apiAuth.ts @@ -3,13 +3,6 @@ import {CurrentUser} from './CurrentUser'; import {SnackReporter} from './snack/SnackManager'; export const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => { - axios.interceptors.request.use((config) => { - if (!config.headers.has('x-gotify-key')) { - config.headers['x-gotify-key'] = currentUser.token(); - } - return config; - }); - axios.interceptors.response.use(undefined, (error) => { if (!error.response) { snack('Gotify server is not reachable, try refreshing the page.'); diff --git a/ui/src/config.ts b/ui/src/config.ts index 16c81d20..00981ded 100644 --- a/ui/src/config.ts +++ b/ui/src/config.ts @@ -4,6 +4,7 @@ export interface IConfig { url: string; register: boolean; version: IVersion; + oidc: boolean; } declare global { @@ -16,6 +17,7 @@ const config: IConfig = { url: 'unset', register: false, version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'}, + oidc: false, ...window.config, }; diff --git a/ui/src/message/WebSocketStore.ts b/ui/src/message/WebSocketStore.ts index 14a158fd..37740386 100644 --- a/ui/src/message/WebSocketStore.ts +++ b/ui/src/message/WebSocketStore.ts @@ -14,13 +14,13 @@ export class WebSocketStore { ) {} public listen = (callback: (msg: IMessage) => void) => { - if (!this.currentUser.token() || this.wsActive) { + if (!this.currentUser.loggedIn || this.wsActive) { return; } this.wsActive = true; const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss'); - const ws = new WebSocket(wsUrl + 'stream?token=' + this.currentUser.token()); + const ws = new WebSocket(wsUrl + 'stream'); ws.onerror = (e) => { this.wsActive = false; @@ -31,6 +31,9 @@ export class WebSocketStore { ws.onclose = () => { this.wsActive = false; + if (!this.currentUser.loggedIn) { + return; + } this.currentUser .tryAuthenticate() .then(() => { diff --git a/ui/src/user/Login.tsx b/ui/src/user/Login.tsx index 161e6ea3..e6e572a3 100644 --- a/ui/src/user/Login.tsx +++ b/ui/src/user/Login.tsx @@ -1,4 +1,5 @@ import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; import Grid from '@mui/material/Grid'; import TextField from '@mui/material/TextField'; import React from 'react'; @@ -80,6 +81,24 @@ const Login = observer(() => { Login + {config.get('oidc') && ( + <> + or +