diff --git a/core/settings.go b/core/settings.go index de9cfe24..db4e989d 100644 --- a/core/settings.go +++ b/core/settings.go @@ -36,6 +36,7 @@ type Settings struct { GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"` GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"` DiscordAuth AuthProviderConfig `form:"discordAuth" json:"discordAuth"` + TwitterAuth AuthProviderConfig `form:"twitterAuth" json:"twitterAuth"` } // NewSettings creates and returns a new default Settings instance. @@ -111,6 +112,10 @@ func NewSettings() *Settings { Enabled: false, AllowRegistrations: true, }, + TwitterAuth: AuthProviderConfig{ + Enabled: false, + AllowRegistrations: true, + }, } } @@ -136,6 +141,7 @@ func (s *Settings) Validate() error { validation.Field(&s.GithubAuth), validation.Field(&s.GitlabAuth), validation.Field(&s.DiscordAuth), + validation.Field(&s.TwitterAuth), ) } @@ -185,6 +191,7 @@ func (s *Settings) RedactClone() (*Settings, error) { &clone.GithubAuth.ClientSecret, &clone.GitlabAuth.ClientSecret, &clone.DiscordAuth.ClientSecret, + &clone.TwitterAuth.ClientSecret, } // mask all sensitive fields @@ -209,6 +216,7 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig { auth.NameGithub: s.GithubAuth, auth.NameGitlab: s.GitlabAuth, auth.NameDiscord: s.DiscordAuth, + auth.NameTwitter: s.TwitterAuth, } } diff --git a/core/settings_test.go b/core/settings_test.go index c186589b..01ccbae0 100644 --- a/core/settings_test.go +++ b/core/settings_test.go @@ -39,6 +39,8 @@ func TestSettingsValidate(t *testing.T) { s.GitlabAuth.ClientId = "" s.DiscordAuth.Enabled = true s.DiscordAuth.ClientId = "" + s.TwitterAuth.Enabled = true + s.TwitterAuth.ClientId = "" // check if Validate() is triggering the members validate methods. err := s.Validate() @@ -103,6 +105,8 @@ func TestSettingsMerge(t *testing.T) { s2.GitlabAuth.ClientId = "gitlab_test" s2.DiscordAuth.Enabled = true s2.DiscordAuth.ClientId = "discord_test" + s2.TwitterAuth.Enabled = true + s2.TwitterAuth.ClientId = "twitter_test" if err := s1.Merge(s2); err != nil { t.Fatal(err) @@ -169,6 +173,7 @@ func TestSettingsRedactClone(t *testing.T) { s1.GithubAuth.ClientSecret = "test123" s1.GitlabAuth.ClientSecret = "test123" s1.DiscordAuth.ClientSecret = "test123" + s1.TwitterAuth.ClientSecret = "test123" s2, err := s1.RedactClone() if err != nil { @@ -180,7 +185,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}/_/#/users/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}/_/#/users/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}/_/#/users/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":7},"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},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"discordAuth":{"enabled":false,"allowRegistrations":true,"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}/_/#/users/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}/_/#/users/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}/_/#/users/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":7},"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},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"discordAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"twitterAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}` if encodedStr := string(encoded); encodedStr != expected { t.Fatalf("Expected %v, got \n%v", expected, encodedStr) @@ -196,6 +201,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) { s.GitlabAuth.ClientId = "gitlab_test" s.GitlabAuth.Enabled = true s.DiscordAuth.ClientId = "discord_test" + s.TwitterAuth.ClientId = "twitter_test" result := s.NamedAuthProviderConfigs() @@ -204,7 +210,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) { t.Fatal(err) } - expected := `{"discord":{"enabled":false,"allowRegistrations":true,"clientId":"discord_test"},"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"}}` + expected := `{"discord":{"enabled":false,"allowRegistrations":true,"clientId":"discord_test"},"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"},"twitter":{"enabled":false,"allowRegistrations":true,"clientId":"twitter_test"}}` if encodedStr := string(encoded); encodedStr != expected { t.Fatalf("Expected the same serialization, got %v", encodedStr) diff --git a/tests/data/data.db b/tests/data/data.db index f9da3d2d..f5858c2a 100644 Binary files a/tests/data/data.db and b/tests/data/data.db differ diff --git a/tools/auth/auth.go b/tools/auth/auth.go index 5dc70413..2b6b1b0c 100644 --- a/tools/auth/auth.go +++ b/tools/auth/auth.go @@ -92,6 +92,8 @@ func NewProviderByName(name string) (Provider, error) { return NewGitlabProvider(), nil case NameDiscord: return NewDiscordProvider(), nil + case NameTwitter: + return NewTwitterProvider(), nil default: return nil, errors.New("Missing provider " + name) } diff --git a/tools/auth/twitter.go b/tools/auth/twitter.go new file mode 100644 index 00000000..d3d88cd0 --- /dev/null +++ b/tools/auth/twitter.go @@ -0,0 +1,60 @@ +package auth + +import ( + "golang.org/x/oauth2" +) + +var _ Provider = (*Twitter)(nil) + +// NameTwitter is the unique name of the Twitter provider. +const NameTwitter string = "twitter" + +// Twitter allows authentication via Twitter OAuth2. +type Twitter struct { + *baseProvider +} + +// NewTwitterProvider creates new Twitter provider instance with some defaults. +func NewTwitterProvider() *Twitter { + return &Twitter{&baseProvider{ + scopes: []string{ + "users.read", + + // we don't actually use this scope, but for some reason it is required by the `/2/users/me` endpoint + // (see https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me) + "tweet.read", + }, + authUrl: "https://twitter.com/i/oauth2/authorize", + tokenUrl: "https://api.twitter.com/2/oauth2/token", + userApiUrl: "https://api.twitter.com/2/users/me?user.fields=id,name,profile_image_url", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Twitter's user api. +func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + // https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me + rawData := struct { + Data struct { + Id string `json:"id"` + Name string `json:"name"` + ProfileImageUrl string `json:"profile_image_url"` + + // NB! At the time of writing, Twitter OAuth2 doesn't support returning the user email address + // (see https://twittercommunity.com/t/which-api-to-get-user-after-oauth2-authorization/162417/33) + Email string `json:"email"` + } `json:"data"` + }{} + + if err := p.FetchRawUserData(token, &rawData); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: rawData.Data.Id, + Name: rawData.Data.Name, + Email: rawData.Data.Email, + AvatarUrl: rawData.Data.ProfileImageUrl, + } + + return user, nil +} diff --git a/ui/src/components/users/UserUpsertPanel.svelte b/ui/src/components/users/UserUpsertPanel.svelte index c04728d4..9aa2e147 100644 --- a/ui/src/components/users/UserUpsertPanel.svelte +++ b/ui/src/components/users/UserUpsertPanel.svelte @@ -37,6 +37,8 @@ confirmClose = true; + activeTab = user.isNew || user.email ? accountTab : providersTab; + return panel?.show(); }