diff --git a/auth/auth.go b/auth/auth.go index 3cd67a9b..15e24c37 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -13,6 +13,9 @@ func Install(app *aero.Application) { // Facebook InstallFacebookAuth(app) + // Twitter + InstallTwitterAuth(app) + // Logout app.Get("/logout", func(ctx *aero.Context) string { if ctx.HasSession() { diff --git a/auth/twitter.go b/auth/twitter.go new file mode 100644 index 00000000..84f857c7 --- /dev/null +++ b/auth/twitter.go @@ -0,0 +1,174 @@ +package auth + +import ( + "errors" + "fmt" + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" + "github.com/gomodule/oauth1/oauth" + jsoniter "github.com/json-iterator/go" + "io/ioutil" + "net/http" + "strings" +) + +// TwitterUser is the user data we receive from Twitter +type TwitterUser struct { + ID string `json:"id_str"` + Email string `json:"email"` + Name string `json:"name"` +} + +// InstallTwitterAuth enables Twitter login for the app. +func InstallTwitterAuth(app *aero.Application) { + // oauth1 configuration defines the API keys, + // the url for the request token, the access token and the authorisation. + config := &oauth.Client{ + TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token", + ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authenticate", + TokenRequestURI: "https://api.twitter.com/oauth/access_token", + Credentials: oauth.Credentials{ + Token: arn.APIKeys.Twitter.ID, + Secret: arn.APIKeys.Twitter.Secret, + }, + } + + // When a user visits /auth/twitter, we ask OAuth1 to give us + // a request token and give us a URL to redirect the user to. + // Once the user has approved the application on that page, + // he'll be redirected back to our servers to the callback page. + app.Get("/auth/twitter", func(ctx *aero.Context) string { + callback := "https://" + ctx.App.Config.Domain + "/auth/twitter/callback" + tempCred, err := config.RequestTemporaryCredentials(nil, callback, nil) + + if err != nil { + fmt.Printf("Error: %s", err) + } + + ctx.Session().Set("tempCred", tempCred) + url := config.AuthorizationURL(tempCred, nil) + return ctx.Redirect(url) + }) + + // This is the redirect URL that we specified in /auth/twitter. + // The user has allowed the application to have access to his data. + // Now we have to check for fraud requests and request user information. + // If both Twitter ID and email can't be found in our DB, register a new user. + // Otherwise, log in the user with the given Twitter ID or email. + app.Get("/auth/twitter/callback", func(ctx *aero.Context) string { + if !ctx.HasSession() { + return ctx.Error(http.StatusUnauthorized, "Twitter login failed", errors.New("Session does not exist")) + } + + session := ctx.Session() + + // get back the request token to get the access token + tempCred, _ := session.Get("tempCred").(*oauth.Credentials) + + if tempCred == nil || tempCred.Token != ctx.Query("oauth_token") { + return ctx.Error(http.StatusBadRequest, "Unknown OAuth request token", nil) + } + + // Get the request token from twitter + tokenCred, _, err := config.RequestToken(nil, tempCred, ctx.Query("oauth_verifier")) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Could not obtain OAuth access token", err) + } + + // Fetch user data from Twitter + resp, err := config.Get(nil, tokenCred, "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true1skip_status=true", nil) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Failed requesting user data from Twitter", err) + } + + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + + // Construct a TwitterUser object + twUser := TwitterUser{} + err = jsoniter.Unmarshal(body, &twUser) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) + } + + // Change googlemail.com to gmail.com + twUser.Email = strings.Replace(twUser.Email, "googlemail.com", "gmail.com", 1) + + // Is this an existing user connecting another social account? + user := utils.GetUser(ctx) + + if user != nil { + // Add TwitterToUser reference + user.ConnectTwitter(twUser.ID) + + // Save in DB + user.Save() + + // Log + authLog.Info("Added Twitter ID to existing account", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + return ctx.Redirect("/") + } + + var getErr error + + // Try to find an existing user via the Twitter user ID + user, getErr = arn.GetUserByTwitterID(twUser.ID) + + if getErr == nil && user != nil { + authLog.Info("User logged in via Twitter ID", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + user.LastLogin = arn.DateTimeUTC() + user.Save() + + session.Set("userId", user.ID) + return ctx.Redirect("/") + } + + // Try to find an existing user via the associated e-mail address + user, getErr = arn.GetUserByEmail(twUser.Email) + + if getErr == nil && user != nil { + authLog.Info("User logged in via Email", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + user.LastLogin = arn.DateTimeUTC() + user.Save() + + session.Set("userId", user.ID) + return ctx.Redirect("/") + } + + // Register new user + user = arn.NewUser() + user.Nick = "tw" + twUser.ID + user.Email = twUser.Email + // The full name is in Name and separated like other services + user.FirstName = twUser.Name + user.LastLogin = arn.DateTimeUTC() + + // Save basic user info already to avoid data inconsistency problems + user.Save() + + // Register user + arn.RegisterUser(user) + + // Connect account to a Twitter account + user.ConnectTwitter(twUser.ID) + + // Save user object again with updated data + user.Save() + + // Login + session.Set("userId", user.ID) + + // Log + authLog.Info("Registered new user via Twitter", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + // Redirect to starting page for new users + return ctx.Redirect(newUserStartRoute) + }) +} diff --git a/pages/frontpage/frontpage.scarlet b/pages/frontpage/frontpage.scarlet index 6b26cd6d..ea79edf1 100644 --- a/pages/frontpage/frontpage.scarlet +++ b/pages/frontpage/frontpage.scarlet @@ -77,4 +77,10 @@ const frontpage-bg-color = rgb(32, 32, 32) background hsl(222, 67%, 42%) :hover - background hsl(222, 67%, 47%) \ No newline at end of file + background hsl(222, 67%, 47%) + +.login-button-twitter + background hsl(203, 89%, 53%) + + :hover + background hsl(203, 89%, 58%) \ No newline at end of file diff --git a/pages/login/login.pixy b/pages/login/login.pixy index c12948d4..43161219 100644 --- a/pages/login/login.pixy +++ b/pages/login/login.pixy @@ -8,4 +8,9 @@ component Login(target string) if arn.APIKeys.Facebook.Secret != "" a.login-button.login-button-facebook(href="/auth/facebook", target=target, data-ajax="false") Icon("facebook") - span Sign in via Facebook \ No newline at end of file + span Sign in via Facebook + + if arn.APIKeys.Twitter.Secret != "" + a.login-button.login-button-twitter(href="/auth/twitter", target=target, data-ajax="false") + Icon("twitter") + span Sign in via Twitter \ No newline at end of file diff --git a/pages/settings/accounts.pixy b/pages/settings/accounts.pixy index 61caf857..d327d9b9 100644 --- a/pages/settings/accounts.pixy +++ b/pages/settings/accounts.pixy @@ -50,6 +50,17 @@ component SettingsAccounts(user *arn.User) else Icon("circle-o") span Not connected + + .widget-section.social-account + label(for="twitter") Twitter: + + a#twitter.button.social-account-button(href="/auth/twitter", data-ajax="false") + if user.Accounts.Twitter.ID != "" + Icon("check") + span Connected + else + Icon("circle-o") + span Not connected .widget.mountable h3.widget-title diff --git a/utils/routetests/All.go b/utils/routetests/All.go index fa7e0e12..d7e6ab01 100644 --- a/utils/routetests/All.go +++ b/utils/routetests/All.go @@ -418,6 +418,8 @@ var routeTests = map[string][]string{ "/auth/google/callback": nil, "/auth/facebook": nil, "/auth/facebook/callback": nil, + "/auth/twitter": nil, + "/auth/twitter/callback": nil, "/dashboard": nil, "/import": nil, "/import/anilist/animelist": nil,