From 6c7fc902c0a1396c654be17ec9de4c6c86a677d9 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Sun, 2 Jul 2017 23:42:46 +0200 Subject: [PATCH] Facebook login --- auth/auth.go | 3 + auth/facebook.go | 172 +++++++++++++++++++++ auth/google.go | 14 +- pages/animelistitem/animelistitem.pixy | 15 +- pages/login/login.pixy | 5 +- pages/login/login.scarlet | 2 +- pages/newsoundtrack/newsoundtrack.pixy | 21 ++- pages/settings/settings.pixy | 28 ++++ pages/settings/settings.scarlet | 2 + patches/user-references/user-references.go | 10 +- styles/input.scarlet | 55 ++----- tests.go | 18 ++- 12 files changed, 267 insertions(+), 78 deletions(-) create mode 100644 auth/facebook.go create mode 100644 pages/settings/settings.scarlet diff --git a/auth/auth.go b/auth/auth.go index 2a855fd2..9cad95f6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -8,6 +8,9 @@ func Install(app *aero.Application) { // Google InstallGoogleAuth(app) + // Facebook + InstallFacebookAuth(app) + // Logout app.Get("/logout", func(ctx *aero.Context) string { if ctx.HasSession() { diff --git a/auth/facebook.go b/auth/facebook.go new file mode 100644 index 00000000..1ac99d60 --- /dev/null +++ b/auth/facebook.go @@ -0,0 +1,172 @@ +package auth + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" + "golang.org/x/oauth2" + "golang.org/x/oauth2/facebook" +) + +// FacebookUser is the user data we receive from Facebook +type FacebookUser struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Gender string `json:"gender"` +} + +// InstallFacebookAuth enables Facebook login for the app. +func InstallFacebookAuth(app *aero.Application) { + config := &oauth2.Config{ + ClientID: arn.APIKeys.Facebook.ID, + ClientSecret: arn.APIKeys.Facebook.Secret, + RedirectURL: "https://" + app.Config.Domain + "/auth/facebook/callback", + Scopes: []string{ + "public_profile", + "email", + }, + Endpoint: facebook.Endpoint, + } + + // Auth + app.Get("/auth/facebook", func(ctx *aero.Context) string { + state := ctx.Session().ID() + url := config.AuthCodeURL(state) + ctx.Redirect(url) + return "" + }) + + // Auth Callback + app.Get("/auth/facebook/callback", func(ctx *aero.Context) string { + if !ctx.HasSession() { + return ctx.Error(http.StatusUnauthorized, "Facebook login failed", errors.New("Session does not exist")) + } + + session := ctx.Session() + + if session.ID() != ctx.Query("state") { + return ctx.Error(http.StatusUnauthorized, "Facebook login failed", errors.New("Incorrect state")) + } + + // Handle the exchange code to initiate a transport + token, err := config.Exchange(oauth2.NoContext, ctx.Query("code")) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Could not obtain OAuth token", err) + } + + // Construct the OAuth client + client := config.Client(oauth2.NoContext, token) + + // Fetch user data from Facebook + resp, err := client.Get("https://graph.facebook.com/me?fields=email,first_name,last_name,gender") + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Failed requesting user data from Facebook", err) + } + + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + + // Construct a FacebookUser object + fbUser := FacebookUser{} + err = json.Unmarshal(body, &fbUser) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) + } + + // Change googlemail.com to gmail.com + fbUser.Email = strings.Replace(fbUser.Email, "googlemail.com", "gmail.com", 1) + + // Is this an existing user connecting another social account? + user := utils.GetUser(ctx) + + if user != nil { + // Add FacebookToUser reference + err = user.ConnectFacebook(fbUser.ID) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "Could not connect account to Facebook account", err) + } + + authLog.Info("Added Facebook 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 Facebook user ID + user, getErr = arn.GetUserFromTable("FacebookToUser", fbUser.ID) + + if getErr == nil && user != nil { + authLog.Info("User logged in via Facebook 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(fbUser.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 = "fb" + fbUser.ID + user.Email = fbUser.Email + user.FirstName = fbUser.FirstName + user.LastName = fbUser.LastName + user.Gender = fbUser.Gender + user.LastLogin = arn.DateTimeUTC() + + // Save basic user info already to avoid data inconsistency problems + user.Save() + + // Register user + err = arn.RegisterUser(user) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "Could not register a new user", err) + } + + // Connect account to a Facebook account + err = user.ConnectFacebook(fbUser.ID) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "Could not connect account to Facebook account", err) + } + + // Save user object again with updated data + user.Save() + + // Login + session.Set("userId", user.ID) + + // Log + authLog.Info("Registered new user via Facebook", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + // Redirect to frontpage + return ctx.Redirect("/") + }) +} diff --git a/auth/google.go b/auth/google.go index 11e9717b..5037140c 100644 --- a/auth/google.go +++ b/auth/google.go @@ -5,6 +5,7 @@ import ( "errors" "io/ioutil" "net/http" + "strings" "github.com/aerogo/aero" "github.com/animenotifier/arn" @@ -43,8 +44,8 @@ func InstallGoogleAuth(app *aero.Application) { // Auth app.Get("/auth/google", func(ctx *aero.Context) string { - sessionID := ctx.Session().ID() - url := config.AuthCodeURL(sessionID) + state := ctx.Session().ID() + url := config.AuthCodeURL(state) ctx.Redirect(url) return "" }) @@ -89,12 +90,13 @@ func InstallGoogleAuth(app *aero.Application) { return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) } + // Change googlemail.com to gmail.com + googleUser.Email = strings.Replace(googleUser.Email, "googlemail.com", "gmail.com", 1) + // Is this an existing user connecting another social account? user := utils.GetUser(ctx) if user != nil { - println("Connected") - // Add GoogleToUser reference err = user.ConnectGoogle(googleUser.Sub) @@ -102,6 +104,8 @@ func InstallGoogleAuth(app *aero.Application) { ctx.Error(http.StatusInternalServerError, "Could not connect account to Google account", err) } + authLog.Info("Added Google ID to existing account", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + return ctx.Redirect("/") } @@ -166,7 +170,7 @@ func InstallGoogleAuth(app *aero.Application) { session.Set("userId", user.ID) // Log - authLog.Info("Registered new user", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + authLog.Info("Registered new user via Google", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) // Redirect to frontpage return ctx.Redirect("/") diff --git a/pages/animelistitem/animelistitem.pixy b/pages/animelistitem/animelistitem.pixy index d4b95315..4a293b93 100644 --- a/pages/animelistitem/animelistitem.pixy +++ b/pages/animelistitem/animelistitem.pixy @@ -5,13 +5,14 @@ component AnimeListItem(viewUser *arn.User, item *arn.AnimeListItem, anime *arn. InputNumber("Episodes", float64(item.Episodes), "Episodes", "Number of episodes you watched", "0", arn.EpisodeCountMax(anime.EpisodeCount), "1") - label(for="Status") Status: - select.widget-element.action(id="Status", data-field="Status", value=item.Status, data-action="save", data-trigger="change") - option(value=arn.AnimeListStatusWatching) Watching - option(value=arn.AnimeListStatusCompleted) Completed - option(value=arn.AnimeListStatusPlanned) Plan to watch - option(value=arn.AnimeListStatusHold) On hold - option(value=arn.AnimeListStatusDropped) Dropped + .widget-input + label(for="Status") Status: + select.widget-element.action(id="Status", data-field="Status", value=item.Status, data-action="save", data-trigger="change") + option(value=arn.AnimeListStatusWatching) Watching + option(value=arn.AnimeListStatusCompleted) Completed + option(value=arn.AnimeListStatusPlanned) Plan to watch + option(value=arn.AnimeListStatusHold) On hold + option(value=arn.AnimeListStatusDropped) Dropped .anime-list-item-rating-edit InputNumber("Rating.Overall", item.Rating.Overall, arn.OverallRatingName(item.Episodes), "Overall rating on a scale of 0 to 10", "0", "10", "0.1") diff --git a/pages/login/login.pixy b/pages/login/login.pixy index ce41d9f9..883cd798 100644 --- a/pages/login/login.pixy +++ b/pages/login/login.pixy @@ -1,4 +1,7 @@ component Login .login-buttons a.login-button(href="/auth/google") - img.login-button-image(src="/images/login/google", alt="Google Login", title="Login with your Google account") \ No newline at end of file + img.login-button-image(src="/images/login/google", alt="Google Login", title="Login with your Google account") + + a.login-button(href="/auth/facebook") + img.login-button-image(src="/images/login/facebook", alt="Facebook Login", title="Login with your Facebook account") \ No newline at end of file diff --git a/pages/login/login.scarlet b/pages/login/login.scarlet index 2a43abf7..36972b8f 100644 --- a/pages/login/login.scarlet +++ b/pages/login/login.scarlet @@ -4,7 +4,7 @@ justify-content center .login-button - // + padding 0.5rem .login-button-image max-width 236px diff --git a/pages/newsoundtrack/newsoundtrack.pixy b/pages/newsoundtrack/newsoundtrack.pixy index 2237f08a..ca2e65e8 100644 --- a/pages/newsoundtrack/newsoundtrack.pixy +++ b/pages/newsoundtrack/newsoundtrack.pixy @@ -2,17 +2,22 @@ component NewSoundTrack(user *arn.User) .widgets .widget h3 New soundtrack - label(for="soundcloud-link") Soundcloud link: - input#soundcloud-link.widget-element(type="text", placeholder="https://soundcloud.com/abc/123") - label(for="youtube-link") Youtube link: - input#youtube-link.widget-element(type="text", placeholder="https://www.youtube.com/watch?v=123") + .widget-input + label(for="soundcloud-link") Soundcloud link: + input#soundcloud-link.widget-element(type="text", placeholder="https://soundcloud.com/abc/123") - label(for="anime-link") Anime link: - input#anime-link.widget-element(type="text", placeholder="https://notify.moe/anime/123") + .widget-input + label(for="youtube-link") Youtube link: + input#youtube-link.widget-element(type="text", placeholder="https://www.youtube.com/watch?v=123") - label(for="osu-link") Osu beatmap (optional): - input#osu-link.widget-element(type="text", placeholder="https://osu.ppy.sh/s/123") + .widget-input + label(for="anime-link") Anime link: + input#anime-link.widget-element(type="text", placeholder="https://notify.moe/anime/123") + + .widget-input + label(for="osu-link") Osu beatmap (optional): + input#osu-link.widget-element(type="text", placeholder="https://osu.ppy.sh/s/123") .buttons button.action(data-action="createSoundTrack", data-trigger="click") diff --git a/pages/settings/settings.pixy b/pages/settings/settings.pixy index 879de0af..5897ec3b 100644 --- a/pages/settings/settings.pixy +++ b/pages/settings/settings.pixy @@ -21,6 +21,34 @@ component Settings(user *arn.User) InputText("Accounts.AnimePlanet.Nick", user.Accounts.AnimePlanet.Nick, "AnimePlanet", "Your username on anime-planet.com") InputText("Accounts.Osu.Nick", user.Accounts.Osu.Nick, "Osu", "Your username on osu.ppy.sh") + .widget.mountable + h3.widget-title + Icon("user-plus") + span Connect + + .widget-input.social-account + label(for="google") Google: + + a#google.button.social-account-button(href="/auth/google") + if user.Accounts.Google.ID != "" + Icon("check") + span Connected + else + Icon("circle-o") + span Not connected + + .widget-input.social-account + label(for="facebook") Facebook: + + a#facebook.button.social-account-button(href="/auth/facebook") + if user.Accounts.Facebook.ID != "" + Icon("check") + span Connected + else + + Icon("circle-o") + span Not connected + //- .widget.mountable(data-api="/api/settings/" + user.ID) //- h3.widget-title //- Icon("cogs") diff --git a/pages/settings/settings.scarlet b/pages/settings/settings.scarlet new file mode 100644 index 00000000..301a0549 --- /dev/null +++ b/pages/settings/settings.scarlet @@ -0,0 +1,2 @@ +.social-account-button + margin-bottom 1rem \ No newline at end of file diff --git a/patches/user-references/user-references.go b/patches/user-references/user-references.go index 31a7d454..c25e37a8 100644 --- a/patches/user-references/user-references.go +++ b/patches/user-references/user-references.go @@ -11,6 +11,7 @@ func main() { arn.DB.DeleteTable("NickToUser") arn.DB.DeleteTable("EmailToUser") arn.DB.DeleteTable("GoogleToUser") + arn.DB.DeleteTable("FacebookToUser") // Get a stream of all users allUsers, err := arn.StreamUsers() @@ -32,10 +33,11 @@ func main() { } if user.Accounts.Google.ID != "" { - arn.DB.Set("GoogleToUser", user.Accounts.Google.ID, &arn.GoogleToUser{ - ID: user.Accounts.Google.ID, - UserID: user.ID, - }) + user.ConnectGoogle(user.Accounts.Google.ID) + } + + if user.Accounts.Facebook.ID != "" { + user.ConnectFacebook(user.Accounts.Facebook.ID) } } diff --git a/styles/input.scarlet b/styles/input.scarlet index 4270571b..2f5581e4 100644 --- a/styles/input.scarlet +++ b/styles/input.scarlet @@ -5,44 +5,29 @@ mixin input-focus // TODO: Replace with alpha(main-color, 20%) function box-shadow 0 0 6px rgba(248, 165, 130, 0.2) -input, textarea, button, select +input, textarea, button, .button, select + ui-element font-family inherit font-size 1em - padding 0.4em 0.8em - border-radius 3px + line-height 1.25em color text-color -input, textarea - border ui-border - background white - box-shadow none - width 100% +input, textarea, select input-focus - + :disabled ui-disabled input - default-transition - :active transform translateY(3px) -// We need this to have a selector with a higher priority than .widget-element:focus -input.widget-element, -textarea.widget-element - input-focus - -textarea - height 10rem - button, .button - ui-element horizontal - font-size 1rem - line-height 1rem - padding 0.75rem 1rem + line-height 1.5em + padding 0.5rem 1rem color link-color + align-items center :hover, &.active @@ -52,35 +37,17 @@ button, .button :active transform translateY(3px) - - // box-shadow 0 0 2px white, 0 -2px 5px rgba(0, 0, 0, 0.08) inset - // :active - // background-color black - // color white - // :focus - // color rgb(0, 0, 0) - // // box-shadow 0 0 6px alpha(mainColor, 20%) - // border 1px solid main-color select - ui-element appearance none -webkit-appearance none -moz-appearance none - font-size 1rem - // padding 0.5em 1em label width 100% padding 0.5rem 0 text-align left -// input[type="submit"]:hover, -// button:hover -// cursor pointer -// text-decoration none - -// button[disabled] -// opacity 0.5 -// :hover -// cursor not-allowed \ No newline at end of file +textarea + line-height 1.5em + height 10rem \ No newline at end of file diff --git a/tests.go b/tests.go index 47160cd1..2658213f 100644 --- a/tests.go +++ b/tests.go @@ -150,14 +150,16 @@ var routeTests = map[string][]string{ }, // Disable these tests because they require authorization - "/auth/google": nil, - "/auth/google/callback": nil, - "/anime/:id/edit": nil, - "/new/thread": nil, - "/new/soundtrack": nil, - "/user": nil, - "/settings": nil, - "/extension/embed": nil, + "/auth/google": nil, + "/auth/google/callback": nil, + "/auth/facebook": nil, + "/auth/facebook/callback": nil, + "/anime/:id/edit": nil, + "/new/thread": nil, + "/new/soundtrack": nil, + "/user": nil, + "/settings": nil, + "/extension/embed": nil, } // API interfaces