support OIDC authentication (#242)

This commit is contained in:
MaysWind
2025-10-24 01:44:55 +08:00
parent d3ab2b94b7
commit 85b05f9e7e
24 changed files with 490 additions and 202 deletions
@@ -0,0 +1,114 @@
package nextcloud
import (
"encoding/json"
"net/http"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
type nextcloudUserInfoResponse struct {
OCS *struct {
Meta *struct {
Status string `json:"status"`
StatusCode int `json:"statuscode"`
} `json:"meta"`
Data *struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display-name"`
} `json:"data"`
} `json:"ocs"`
}
// NextcloudOAuth2DataSource represents Nextcloud OAuth 2.0 data source
type NextcloudOAuth2DataSource struct {
common.CommonOAuth2DataSource
baseUrl string
}
// GetAuthUrl returns the authentication url of the Nextcloud data source
func (s *NextcloudOAuth2DataSource) GetAuthUrl() string {
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-login_redirector-authorize
return s.baseUrl + "apps/oauth2/authorize"
}
// GetTokenUrl returns the token url of the Nextcloud data source
func (s *NextcloudOAuth2DataSource) GetTokenUrl() string {
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-oauth_api-get-token
return s.baseUrl + "apps/oauth2/api/v1/token"
}
// GetUserInfoRequest returns the user info request of the Nextcloud data source
func (s *NextcloudOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/provisioning_api-users-get-current-user
req, err := http.NewRequest("GET", s.baseUrl+"ocs/v2.php/cloud/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("OCS-APIRequest", "true")
return req, nil
}
// GetScopes returns the scopes required by the Nextcloud provider
func (s *NextcloudOAuth2DataSource) GetScopes() []string {
return []string{}
}
// ParseUserInfo returns the user info by parsing the response body
func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
userInfoResp := &nextcloudUserInfoResponse{}
err := json.Unmarshal(body, &userInfoResp)
if err != nil {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] failed to parse user info response body, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] invalid user info response body")
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.OCS.Meta.StatusCode != 200 {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode)
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.OCS.Data.ID == "" {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info id is empty")
return nil, errs.ErrCannotRetrieveUserInfo
}
return &data.OAuth2UserInfo{
UserName: userInfoResp.OCS.Data.ID,
Email: userInfoResp.OCS.Data.Email,
NickName: userInfoResp.OCS.Data.DisplayName,
}, nil
}
// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance
func NewNextcloudOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
if len(config.OAuth2NextcloudBaseUrl) < 1 {
return nil, errs.ErrInvalidOAuth2Config
}
baseUrl := config.OAuth2NextcloudBaseUrl
if baseUrl[len(baseUrl)-1] != '/' {
baseUrl += "/"
}
return common.NewCommonOAuth2Provider(config, redirectUrl, &NextcloudOAuth2DataSource{
baseUrl: baseUrl,
}), nil
}
@@ -0,0 +1,116 @@
package nextcloud
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
func TestNewNextcloudOAuth2Provider(t *testing.T) {
provider, err := NewNextcloudOAuth2Provider(&settings.Config{
OAuth2NextcloudBaseUrl: "https://example.com/",
}, "")
assert.Nil(t, err)
assert.Equal(t, "https://example.com/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
assert.Equal(t, "https://example.com/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
provider, err = NewNextcloudOAuth2Provider(&settings.Config{
OAuth2NextcloudBaseUrl: "https://example.com/index.php",
}, "")
assert.Nil(t, err)
assert.Equal(t, "https://example.com/index.php/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
assert.Equal(t, "https://example.com/index.php/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
provider, err = NewNextcloudOAuth2Provider(&settings.Config{}, "")
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
}
func TestNextcloudOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{baseUrl: "https://example.com/"}
req, err := datasource.GetUserInfoRequest()
assert.Nil(t, err)
assert.Equal(t, "GET", req.Method)
assert.Equal(t, "https://example.com/ocs/v2.php/cloud/user", req.URL.String())
assert.Equal(t, "application/json", req.Header.Get("Accept"))
assert.Equal(t, "true", req.Header.Get("OCS-APIRequest"))
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200
},
"data": {
"id": "user1",
"email": "user1@example.com",
"display-name": "User"
}
}
}`
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "user1", info.UserName)
assert.Equal(t, "user1@example.com", info.Email)
assert.Equal(t, "User", info.NickName)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{"ocs": {}}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{
"ocs": {
"meta": {
"status": "error",
"statuscode": 400
},
"data": {}
}
}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200
},
"data": {
"id": "",
"email": "user1@example.com",
"display-name": "User One"
}
}
}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}