diff --git a/apis/settings_test.go b/apis/settings_test.go index 5a79e721..c51bd395 100644 --- a/apis/settings_test.go +++ b/apis/settings_test.go @@ -59,6 +59,7 @@ func TestSettingsList(t *testing.T) { `"kakaoAuth":{`, `"twitchAuth":{`, `"stravaAuth":{`, + `"giteeAuth":{`, `"secret":"******"`, `"clientSecret":"******"`, }, @@ -127,6 +128,7 @@ func TestSettingsSet(t *testing.T) { `"kakaoAuth":{`, `"twitchAuth":{`, `"stravaAuth":{`, + `"giteeAuth":{`, `"secret":"******"`, `"clientSecret":"******"`, `"appName":"acme_test"`, @@ -184,6 +186,7 @@ func TestSettingsSet(t *testing.T) { `"kakaoAuth":{`, `"twitchAuth":{`, `"stravaAuth":{`, + `"giteeAuth":{`, `"secret":"******"`, `"clientSecret":"******"`, `"appName":"update_test"`, diff --git a/models/settings/settings.go b/models/settings/settings.go index 00eacb79..75cc47d5 100644 --- a/models/settings/settings.go +++ b/models/settings/settings.go @@ -45,6 +45,7 @@ type Settings struct { KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"` TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"` StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"` + GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"` } // New creates and returns a new default Settings instance. @@ -128,6 +129,9 @@ func New() *Settings { StravaAuth: AuthProviderConfig{ Enabled: false, }, + GiteeAuth: AuthProviderConfig{ + Enabled: false, + }, } } @@ -158,6 +162,7 @@ func (s *Settings) Validate() error { validation.Field(&s.KakaoAuth), validation.Field(&s.TwitchAuth), validation.Field(&s.StravaAuth), + validation.Field(&s.GiteeAuth), ) } @@ -213,6 +218,7 @@ func (s *Settings) RedactClone() (*Settings, error) { &clone.KakaoAuth.ClientSecret, &clone.TwitchAuth.ClientSecret, &clone.StravaAuth.ClientSecret, + &clone.GiteeAuth.ClientSecret, } // mask all sensitive fields @@ -243,6 +249,7 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig { auth.NameKakao: s.KakaoAuth, auth.NameTwitch: s.TwitchAuth, auth.NameStrava: s.StravaAuth, + auth.NameGitee: s.GiteeAuth, } } diff --git a/models/settings/settings_test.go b/models/settings/settings_test.go index e07ffd78..592dbf1d 100644 --- a/models/settings/settings_test.go +++ b/models/settings/settings_test.go @@ -50,6 +50,8 @@ func TestSettingsValidate(t *testing.T) { s.TwitchAuth.ClientId = "" s.StravaAuth.Enabled = true s.StravaAuth.ClientId = "" + s.GiteeAuth.Enabled = true + s.GiteeAuth.ClientId = "" // check if Validate() is triggering the members validate methods. err := s.Validate() @@ -79,6 +81,7 @@ func TestSettingsValidate(t *testing.T) { `"kakaoAuth":{`, `"twitchAuth":{`, `"stravaAuth":{`, + `"giteeAuth":{`, } errBytes, _ := json.Marshal(err) @@ -129,6 +132,8 @@ func TestSettingsMerge(t *testing.T) { s2.TwitchAuth.ClientId = "twitch_test" s2.StravaAuth.Enabled = true s2.StravaAuth.ClientId = "strava_test" + s2.GiteeAuth.Enabled = true + s2.GiteeAuth.ClientId = "gitee_test" if err := s1.Merge(s2); err != nil { t.Fatal(err) @@ -201,6 +206,7 @@ func TestSettingsRedactClone(t *testing.T) { s1.KakaoAuth.ClientSecret = "test123" s1.TwitchAuth.ClientSecret = "test123" s1.StravaAuth.ClientSecret = "test123" + s1.GiteeAuth.ClientSecret = "test123" s2, err := s1.RedactClone() if err != nil { @@ -212,7 +218,7 @@ func TestSettingsRedactClone(t *testing.T) { t.Fatal(err) } - expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"}}` + expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"},"giteeAuth":{"enabled":false,"clientSecret":"******"}}` if encodedStr := string(encoded); encodedStr != expected { t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr) @@ -234,6 +240,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) { s.KakaoAuth.ClientId = "kakao_test" s.TwitchAuth.ClientId = "twitch_test" s.StravaAuth.ClientId = "strava_test" + s.GiteeAuth.ClientId = "gitee_test" result := s.NamedAuthProviderConfigs() @@ -255,6 +262,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) { `"kakao":{"enabled":false,"clientId":"kakao_test"}`, `"twitch":{"enabled":false,"clientId":"twitch_test"}`, `"strava":{"enabled":false,"clientId":"strava_test"}`, + `"gitee":{"enabled":false,"clientId":"gitee_test"}`, } for _, p := range expectedParts { if !strings.Contains(encodedStr, p) { diff --git a/tools/auth/auth.go b/tools/auth/auth.go index 5f16a2b9..41abf126 100644 --- a/tools/auth/auth.go +++ b/tools/auth/auth.go @@ -107,6 +107,8 @@ func NewProviderByName(name string) (Provider, error) { return NewTwitchProvider(), nil case NameStrava: return NewStravaProvider(), nil + case NameGitee: + return NewGiteeProvider(), nil default: return nil, errors.New("Missing provider " + name) } diff --git a/tools/auth/auth_test.go b/tools/auth/auth_test.go index 75243921..8b43bbd8 100644 --- a/tools/auth/auth_test.go +++ b/tools/auth/auth_test.go @@ -117,4 +117,13 @@ func TestNewProviderByName(t *testing.T) { if _, ok := p.(*auth.Strava); !ok { t.Error("Expected to be instance of *auth.Strava") } + + // gitee + p, err = auth.NewProviderByName(auth.NameGitee) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Gitee); !ok { + t.Error("Expected to be instance of *auth.Gitee") + } } diff --git a/tools/auth/gitee.go b/tools/auth/gitee.go new file mode 100644 index 00000000..af90dc5d --- /dev/null +++ b/tools/auth/gitee.go @@ -0,0 +1,107 @@ +package auth + +import ( + "encoding/json" + "io" + "strconv" + + "golang.org/x/oauth2" +) + +var _ Provider = (*Gitee)(nil) + +// NameGitee is the unique name of the Gitee provider. +const NameGitee string = "gitee" + +// Gitee allows authentication via Gitee OAuth2. +type Gitee struct { + *baseProvider +} + +// NewGiteeProvider creates new Gitee provider instance with some defaults. +func NewGiteeProvider() *Gitee { + return &Gitee{&baseProvider{ + scopes: []string{"user_info", "emails"}, + authUrl: "https://gitee.com/oauth/authorize", + tokenUrl: "https://gitee.com/oauth/token", + userApiUrl: "https://gitee.com/api/v5/user", + }} +} + +// FetchAuthUser returns an AuthUser instance based the Gitee's user api. +// +// API reference: https://gitee.com/api/v5/swagger#/getV5User +func (p *Gitee) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Login string `json:"login"` + Id int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarUrl string `json:"avatar_url"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: strconv.Itoa(extracted.Id), + Name: extracted.Name, + Username: extracted.Login, + Email: extracted.Email, + AvatarUrl: extracted.AvatarUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, + } + + // in case user set "Keep my email address private", + // email should be retrieved via extra API request + if user.Email == "" { + client := p.Client(token) + + response, err := client.Get("https://gitee.com/api/v5/emails") + if err != nil { + return user, err + } + defer response.Body.Close() + + content, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + emails := []struct { + Email string + State string + Scope []string + }{} + if err := json.Unmarshal(content, &emails); err != nil { + return user, err + } + + // extract the verified primary email + + // + // API reference: https://gitee.com/api/v5/swagger#/getV5Emails + outer: + for _, email := range emails { + for _, scope := range email.Scope { + if email.State == "confirmed" && scope == "primary" { + user.Email = email.Email + break outer + } + } + } + } + + return user, nil +}