From 50fce1f3cf1c82bb9c80b37a38568bc49800da32 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Sun, 13 Nov 2022 13:05:06 +0200 Subject: [PATCH] [#979] added Kakao OAuth2 provider --- apis/settings_test.go | 3 +++ core/settings.go | 7 +++++ core/settings_test.go | 31 ++++++++++++++++----- tools/auth/auth.go | 2 ++ tools/auth/auth_test.go | 18 +++++++++++++ tools/auth/kakao.go | 60 +++++++++++++++++++++++++++++++++++++++++ ui/src/providers.js | 4 +++ 7 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 tools/auth/kakao.go diff --git a/apis/settings_test.go b/apis/settings_test.go index 477332cd6..ea195514c 100644 --- a/apis/settings_test.go +++ b/apis/settings_test.go @@ -56,6 +56,7 @@ func TestSettingsList(t *testing.T) { `"discordAuth":{`, `"microsoftAuth":{`, `"spotifyAuth":{`, + `"kakaoAuth":{`, `"secret":"******"`, `"clientSecret":"******"`, }, @@ -121,6 +122,7 @@ func TestSettingsSet(t *testing.T) { `"discordAuth":{`, `"microsoftAuth":{`, `"spotifyAuth":{`, + `"kakaoAuth":{`, `"secret":"******"`, `"clientSecret":"******"`, `"appName":"Acme"`, @@ -175,6 +177,7 @@ func TestSettingsSet(t *testing.T) { `"discordAuth":{`, `"microsoftAuth":{`, `"spotifyAuth":{`, + `"kakaoAuth":{`, `"secret":"******"`, `"clientSecret":"******"`, `"appName":"update_test"`, diff --git a/core/settings.go b/core/settings.go index 4ccde891d..62ef54cac 100644 --- a/core/settings.go +++ b/core/settings.go @@ -41,6 +41,7 @@ type Settings struct { TwitterAuth AuthProviderConfig `form:"twitterAuth" json:"twitterAuth"` MicrosoftAuth AuthProviderConfig `form:"microsoftAuth" json:"microsoftAuth"` SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"` + KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"` } // NewSettings creates and returns a new default Settings instance. @@ -115,6 +116,9 @@ func NewSettings() *Settings { SpotifyAuth: AuthProviderConfig{ Enabled: false, }, + KakaoAuth: AuthProviderConfig{ + Enabled: false, + }, } } @@ -142,6 +146,7 @@ func (s *Settings) Validate() error { validation.Field(&s.TwitterAuth), validation.Field(&s.MicrosoftAuth), validation.Field(&s.SpotifyAuth), + validation.Field(&s.KakaoAuth), ) } @@ -194,6 +199,7 @@ func (s *Settings) RedactClone() (*Settings, error) { &clone.TwitterAuth.ClientSecret, &clone.MicrosoftAuth.ClientSecret, &clone.SpotifyAuth.ClientSecret, + &clone.KakaoAuth.ClientSecret, } // mask all sensitive fields @@ -221,6 +227,7 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig { auth.NameTwitter: s.TwitterAuth, auth.NameMicrosoft: s.MicrosoftAuth, auth.NameSpotify: s.SpotifyAuth, + auth.NameKakao: s.KakaoAuth, } } diff --git a/core/settings_test.go b/core/settings_test.go index 3e40410fb..f6c14dba9 100644 --- a/core/settings_test.go +++ b/core/settings_test.go @@ -43,6 +43,8 @@ func TestSettingsValidate(t *testing.T) { s.MicrosoftAuth.ClientId = "" s.SpotifyAuth.Enabled = true s.SpotifyAuth.ClientId = "" + s.KakaoAuth.Enabled = true + s.KakaoAuth.ClientId = "" // check if Validate() is triggering the members validate methods. err := s.Validate() @@ -69,6 +71,7 @@ func TestSettingsValidate(t *testing.T) { `"twitterAuth":{`, `"microsoftAuth":{`, `"spotifyAuth":{`, + `"kakaoAuth":{`, } errBytes, _ := json.Marshal(err) @@ -113,6 +116,8 @@ func TestSettingsMerge(t *testing.T) { s2.MicrosoftAuth.ClientId = "microsoft_test" s2.SpotifyAuth.Enabled = true s2.SpotifyAuth.ClientId = "spotify_test" + s2.KakaoAuth.Enabled = true + s2.KakaoAuth.ClientId = "kakao_test" if err := s1.Merge(s2); err != nil { t.Fatal(err) @@ -182,6 +187,7 @@ func TestSettingsRedactClone(t *testing.T) { s1.TwitterAuth.ClientSecret = "test123" s1.MicrosoftAuth.ClientSecret = "test123" s1.SpotifyAuth.ClientSecret = "test123" + s1.KakaoAuth.ClientSecret = "test123" s2, err := s1.RedactClone() if err != nil { @@ -193,7 +199,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":"******","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":"******"}}` + 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":"******","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":"******"}}` if encodedStr := string(encoded); encodedStr != expected { t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr) @@ -212,6 +218,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) { s.TwitterAuth.ClientId = "twitter_test" s.MicrosoftAuth.ClientId = "microsoft_test" s.SpotifyAuth.ClientId = "spotify_test" + s.KakaoAuth.ClientId = "kakao_test" result := s.NamedAuthProviderConfigs() @@ -219,11 +226,23 @@ func TestNamedAuthProviderConfigs(t *testing.T) { if err != nil { t.Fatal(err) } - - expected := `{"discord":{"enabled":false,"clientId":"discord_test"},"facebook":{"enabled":false,"clientId":"facebook_test"},"github":{"enabled":false,"clientId":"github_test"},"gitlab":{"enabled":true,"clientId":"gitlab_test"},"google":{"enabled":false,"clientId":"google_test"},"microsoft":{"enabled":false,"clientId":"microsoft_test"},"spotify":{"enabled":false,"clientId":"spotify_test"},"twitter":{"enabled":false,"clientId":"twitter_test"}}` - - if encodedStr := string(encoded); encodedStr != expected { - t.Fatalf("Expected the same serialization, got \n%v", encodedStr) + encodedStr := string(encoded) + + expectedParts := []string{ + `"discord":{"enabled":false,"clientId":"discord_test"}`, + `"facebook":{"enabled":false,"clientId":"facebook_test"}`, + `"github":{"enabled":false,"clientId":"github_test"}`, + `"gitlab":{"enabled":true,"clientId":"gitlab_test"}`, + `"google":{"enabled":false,"clientId":"google_test"}`, + `"microsoft":{"enabled":false,"clientId":"microsoft_test"}`, + `"spotify":{"enabled":false,"clientId":"spotify_test"}`, + `"twitter":{"enabled":false,"clientId":"twitter_test"}`, + `"kakao":{"enabled":false,"clientId":"kakao_test"}`, + } + for _, p := range expectedParts { + if !strings.Contains(encodedStr, p) { + t.Fatalf("Expected \n%s \nin \n%s", p, encodedStr) + } } } diff --git a/tools/auth/auth.go b/tools/auth/auth.go index b926e6a56..f1b40f3ce 100644 --- a/tools/auth/auth.go +++ b/tools/auth/auth.go @@ -99,6 +99,8 @@ func NewProviderByName(name string) (Provider, error) { return NewMicrosoftProvider(), nil case NameSpotify: return NewSpotifyProvider(), nil + case NameKakao: + return NewKakaoProvider(), nil default: return nil, errors.New("Missing provider " + name) } diff --git a/tools/auth/auth_test.go b/tools/auth/auth_test.go index d2d05573d..9fe52e227 100644 --- a/tools/auth/auth_test.go +++ b/tools/auth/auth_test.go @@ -55,6 +55,15 @@ func TestNewProviderByName(t *testing.T) { t.Error("Expected to be instance of *auth.Gitlab") } + // twitter + p, err = auth.NewProviderByName(auth.NameTwitter) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Twitter); !ok { + t.Error("Expected to be instance of *auth.Twitter") + } + // discord p, err = auth.NewProviderByName(auth.NameDiscord) if err != nil { @@ -81,4 +90,13 @@ func TestNewProviderByName(t *testing.T) { if _, ok := p.(*auth.Spotify); !ok { t.Error("Expected to be instance of *auth.Spotify") } + + // kakao + p, err = auth.NewProviderByName(auth.NameKakao) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Kakao); !ok { + t.Error("Expected to be instance of *auth.Kakao") + } } diff --git a/tools/auth/kakao.go b/tools/auth/kakao.go new file mode 100644 index 000000000..e1250c5b6 --- /dev/null +++ b/tools/auth/kakao.go @@ -0,0 +1,60 @@ +package auth + +import ( + "strconv" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/kakao" +) + +var _ Provider = (*Kakao)(nil) + +// NameKakao is the unique name of the Kakao provider. +const NameKakao string = "kakao" + +// Kakao allows authentication via Kakao OAuth2. +type Kakao struct { + *baseProvider +} + +// NewKakaoProvider creates a new Kakao provider instance with some defaults. +func NewKakaoProvider() *Kakao { + return &Kakao{&baseProvider{ + scopes: []string{"account_email", "profile_nickname", "profile_image"}, + authUrl: kakao.Endpoint.AuthURL, + tokenUrl: kakao.Endpoint.TokenURL, + userApiUrl: "https://kapi.kakao.com/v2/user/me", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Kakao's user api. +func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + // https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response + rawData := struct { + Id int `json:"id"` + Profile struct { + Nickname string `json:"nickname"` + ImageUrl string `json:"profile_image"` + } `json:"properties"` + KakaoAccount struct { + Email string `json:"email"` + IsEmailVerified bool `json:"is_email_verified"` + IsEmailValid bool `json:"is_email_valid"` + } `json:"kakao_account"` + }{} + + if err := p.FetchRawUserData(token, &rawData); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: strconv.Itoa(rawData.Id), + Username: rawData.Profile.Nickname, + AvatarUrl: rawData.Profile.ImageUrl, + } + if rawData.KakaoAccount.IsEmailValid && rawData.KakaoAccount.IsEmailVerified { + user.Email = rawData.KakaoAccount.Email + } + + return user, nil +} diff --git a/ui/src/providers.js b/ui/src/providers.js index 12c777e99..947350c05 100644 --- a/ui/src/providers.js +++ b/ui/src/providers.js @@ -44,4 +44,8 @@ export default { title: "Spotify", icon: "ri-spotify-fill", }, + kakaoAuth: { + title: "Kakao", + icon: "ri-kakao-talk-fill", + }, };