diff --git a/account.go b/account.go index c41f24d..6fb8053 100644 --- a/account.go +++ b/account.go @@ -1,1099 +1,1104 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "encoding/json" "fmt" "html/template" "net/http" "regexp" "strings" "sync" "time" "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/guregu/null/zero" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) type ( userSettings struct { Username string `schema:"username" json:"username"` Email string `schema:"email" json:"email"` NewPass string `schema:"new-pass" json:"new_pass"` OldPass string `schema:"current-pass" json:"current_pass"` IsLogOut bool `schema:"logout" json:"logout"` } UserPage struct { page.StaticPage PageTitle string Separator template.HTML IsAdmin bool CanInvite bool } ) func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage { up := &UserPage{ StaticPage: pageForReq(app, r), PageTitle: title, } up.Username = u.Username up.Flashes = flashes up.Path = r.URL.Path up.IsAdmin = u.IsAdmin() up.CanInvite = canUserInvite(app.cfg, up.IsAdmin) return up } func canUserInvite(cfg *config.Config, isAdmin bool) bool { return cfg.App.UserInvites != "" && (isAdmin || cfg.App.UserInvites != "admin") } func (up *UserPage) SetMessaging(u *User) { //up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) } const ( loginAttemptExpiration = 3 * time.Second ) var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead") func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { _, err := signup(app, w, r) return err } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r) // Get params var ur userRegistration if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&ur) if err != nil { log.Error("Couldn't parse signup JSON request: %v\n", err) return nil, ErrBadJSON } } else { // Check if user is already logged in u := getUserSession(app, r) if u != nil { return &AuthUser{User: u}, nil } err := r.ParseForm() if err != nil { log.Error("Couldn't parse signup form request: %v\n", err) return nil, ErrBadFormData } err = app.formDecoder.Decode(&ur, r.PostForm) if err != nil { log.Error("Couldn't decode signup form request: %v\n", err) return nil, ErrBadFormData } } return signupWithRegistration(app, ur, w, r) } func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r) // Validate required params (alias) if signup.Alias == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."} } if signup.Pass == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."} } var desiredUsername string if signup.Normalize { // With this option we simply conform the username to what we expect // without complaining. Since they might've done something funny, like // enter: write.as/Way Out There, we'll use their raw input for the new // collection name and sanitize for the slug / username. desiredUsername = signup.Alias signup.Alias = getSlug(signup.Alias, "") } if !author.IsValidUsername(app.cfg, signup.Alias) { // Ensure the username is syntactically correct. return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."} } // Handle empty optional params // TODO: remove this var createdWithPass := true hashedPass, err := auth.HashPass([]byte(signup.Pass)) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Create struct to insert u := &User{ Username: signup.Alias, HashedPass: hashedPass, HasPass: createdWithPass, - Email: zero.NewString("", signup.Email != ""), + Email: prepareUserEmail(signup.Email, app.keys.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } - if signup.Email != "" { - encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email) - if err != nil { - log.Error("Unable to encrypt email: %s\n", err) - } else { - u.Email.String = string(encEmail) - } - } // Create actual user if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil { return nil, err } // Log invite if needed if signup.InviteCode != "" { cu, err := app.db.GetUserForAuth(signup.Alias) if err != nil { return nil, err } err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID) if err != nil { return nil, err } } // Add back unencrypted data for response if signup.Email != "" { u.Email.String = signup.Email } resUser := &AuthUser{ User: u, } if !createdWithPass { resUser.Password = signup.Pass } title := signup.Alias if signup.Normalize { title = desiredUsername } resUser.Collections = &[]Collection{ { Alias: signup.Alias, Title: title, }, } var token string if reqJSON && !signup.Web { token, err = app.db.GetAccessToken(u.ID) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser.AccessToken = token } else { session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 log.Error("Session: %v; ignoring", err) } session.Values[cookieUserVal] = resUser.User.Cookie() err = session.Save(r, w) if err != nil { log.Error("Couldn't save session: %v", err) return nil, err } } if reqJSON { return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated) } return resUser, nil } func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error { session, err := app.sessionStore.Get(r, cookieName) if err != nil { return ErrInternalCookieSession } // Ensure user has an email or password set before they go, so they don't // lose access to their account. val := session.Values[cookieUserVal] var u = &User{} var ok bool if u, ok = val.(*User); !ok { log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values) err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } u, err = app.db.GetUserByID(u.ID) if err != nil && err != ErrUserNotFound { return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."} } session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } t := auth.GetToken(accessToken) if len(t) == 0 { return ErrNoAccessToken } err := app.db.DeleteToken(t) if err != nil { return err } return impart.HTTPError{Status: http.StatusNoContent} } func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { var earlyError string oneTimeToken := r.FormValue("with") if oneTimeToken != "" { log.Info("Calling login with one-time token.") err := login(app, w, r) if err != nil { log.Info("Received error: %v", err) earlyError = fmt.Sprintf("%s", err) } } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session; ignoring: %v", err) } p := &struct { page.StaticPage To string Message template.HTML Flashes []template.HTML LoginUsername string }{ pageForReq(app, r), r.FormValue("to"), template.HTML(""), []template.HTML{}, getTempInfo(app, "login-user", r, w), } if earlyError != "" { p.Flashes = append(p.Flashes, template.HTML(earlyError)) } // Display any error messages flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = pages["login.tmpl"].ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render login: %v", err) return err } return nil } func webLogin(app *App, w http.ResponseWriter, r *http.Request) error { err := login(app, w, r) if err != nil { username := r.FormValue("alias") // Login request was unsuccessful; save the error in the session and redirect them if err, ok := err.(impart.HTTPError); ok { session, _ := app.sessionStore.Get(r, cookieName) if session != nil { session.AddFlash(err.Message) session.Save(r, w) } if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 { // Retain fixed username recommendation for the login form username = m[1] } } // Pass along certain information saveTempInfo(app, "login-user", username, r, w) // Retain post-login URL if one was given redirectTo := "/login" postLoginRedirect := r.FormValue("to") if postLoginRedirect != "" { redirectTo += "?to=" + postLoginRedirect } log.Error("Unable to login: %v", err) return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo} } return nil } var loginAttemptUsers = sync.Map{} func login(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) oneTimeToken := r.FormValue("with") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") redirectTo := r.FormValue("to") if redirectTo == "" { if app.cfg.App.SingleUser { redirectTo = "/me/new" } else { redirectTo = "/" } } var u *User var err error var signin userCredentials // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") userID := app.db.GetUserID(oneTimeToken) if userID == -1 { log.Error("Login: Got user -1 from token") err := ErrBadAccessToken err.Message = "Expired or invalid login code." return err } log.Info("Login: Found user %d.", userID) u, err = app.db.GetUserByID(userID) if err != nil { log.Error("Unable to fetch user on one-time token login: %v", err) return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."} } log.Info("Login: Got user via token") } else { // Get params if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&signin) if err != nil { log.Error("Couldn't parse signin JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse signin form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&signin, r.PostForm) if err != nil { log.Error("Couldn't decode signin form request: %v\n", err) return ErrBadFormData } } log.Info("Login: Attempting login for '%s'", signin.Alias) // Validate required params (all) if signin.Alias == "" { msg := "Parameter `alias` required." if signin.Web { msg = "A username is required." } return impart.HTTPError{http.StatusBadRequest, msg} } if !signin.EmailLogin && signin.Pass == "" { msg := "Parameter `pass` required." if signin.Web { msg = "A password is required." } return impart.HTTPError{http.StatusBadRequest, msg} } // Prevent excessive login attempts on the same account // Skip this check in dev environment if !app.cfg.Server.Dev { now := time.Now() attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration)) if att { if attemptExpTime, ok := attemptExp.(time.Time); ok { if attemptExpTime.After(now) { // This user attempted previously, and the period hasn't expired yet return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."} } else { // This user attempted previously, but the time expired; free up space loginAttemptUsers.Delete(signin.Alias) } } else { log.Error("Unable to cast expiration to time") } } } // Retrieve password u, err = app.db.GetUserForAuth(signin.Alias) if err != nil { log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err) if strings.IndexAny(signin.Alias, "@") > 0 { log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message) return ErrUserNotFoundEmail } return err } // Authenticate if u.Email.String == "" { // User has no email set, so check if they haven't added a password, either, // so we can return a more helpful error message. if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass { log.Info("Tried logging in to %s, but no password or email.", signin.Alias) return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."} } } if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } } if reqJSON && !signin.Web { var token string if r.Header.Get("User-Agent") == "" { // Get last created token when User-Agent is empty token = app.db.FetchLastAccessToken(u.ID) if token == "" { token, err = app.db.GetAccessToken(u.ID) } } else { token, err = app.db.GetAccessToken(u.ID) } if err != nil { log.Error("Login: Unable to create access token: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser := getVerboseAuthUser(app, token, u, verbose) return impart.WriteSuccess(w, resUser, http.StatusOK) } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Login: Session: %v; ignoring", err) } // Remove unwanted data session.Values[cookieUserVal] = u.Cookie() err = session.Save(r, w) if err != nil { log.Error("Login: Couldn't save session: %v", err) // TODO: return error } // Send success if reqJSON { return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK) } log.Info("Login: Redirecting to %s", redirectTo) w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser { resUser := &AuthUser{ AccessToken: token, User: u, } // Fetch verbose user data if requested if verbose { posts, err := app.db.GetUserPosts(u) if err != nil { log.Error("Login: Unable to get user posts: %v", err) } colls, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("Login: Unable to get user collections: %v", err) } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { // TODO: correct error meesage log.Error("Login: Unable to get user collections: %v", err) } resUser.Posts = posts resUser.Collections = colls resUser.User.HasPass = passIsSet } return resUser } func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error { // Fetch extra user data p := NewUserPage(app, r, u, "Export", nil) showUserPage(w, "export", p) return nil } func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var filename string var u = &User{} reqJSON := IsJSON(r) if reqJSON { // Use given Authorization header accessToken := r.Header.Get("Authorization") if accessToken == "" { return nil, filename, ErrNoAccessToken } userID := app.db.GetUserID(accessToken) if userID == -1 { return nil, filename, ErrBadAccessToken } var err error u, err = app.db.GetUserByID(userID) if err != nil { return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."} } } else { // Use user cookie session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Session: %v; ignoring", err) } val := session.Values[cookieUserVal] var ok bool if u, ok = val.(*User); !ok { return nil, filename, ErrNotLoggedIn } } filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") // Fetch data we're exporting var err error var data []byte posts, err := app.db.GetUserPosts(u) if err != nil { return data, filename, err } // Export as CSV if strings.HasSuffix(r.URL.Path, ".csv") { data = exportPostsCSV(app.cfg.App.Host, u, posts) return data, filename, err } if strings.HasSuffix(r.URL.Path, ".zip") { data = exportPostsZip(u, posts) return data, filename, err } if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(posts, "", "\t") } else { data, err = json.Marshal(posts) } return data, filename, err } func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var err error filename := "" u := getUserSession(app, r) if u == nil { return nil, filename, ErrNotLoggedIn } filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") exportUser := compileFullExport(app, u) var data []byte if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(exportUser, "", "\t") } else { data, err = json.Marshal(exportUser) } return data, filename, err } func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) uObj := struct { ID int64 `json:"id,omitempty"` Username string `json:"username,omitempty"` }{} var err error if reqJSON { _, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization")) if err != nil { return err } } else { u := getUserSession(app, r) if u == nil { return impart.WriteSuccess(w, uObj, http.StatusOK) } uObj.Username = u.Username } return impart.WriteSuccess(w, uObj, http.StatusOK) } func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } var err error p := GetPostsCache(u.ID) if p == nil { userPostsCache.Lock() if userPostsCache.users[u.ID].ready == nil { userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})} userPostsCache.Unlock() p, err = app.db.GetUserPosts(u) if err != nil { return err } CachePosts(u.ID, p) } else { userPostsCache.Unlock() <-userPostsCache.users[u.ID].ready p = GetPostsCache(u.ID) } } return impart.WriteSuccess(w, p, http.StatusOK) } func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } p, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p, err := app.db.GetAnonymousPosts(u) if err != nil { log.Error("unable to fetch anon posts: %v", err) } // nil-out AnonymousPosts slice for easy detection in the template if p != nil && len(*p) == 0 { p = nil } f, err := getSessionFlashes(app, w, r, nil) if err != nil { log.Error("unable to fetch flashes: %v", err) } c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } suspended, err := app.db.IsUserSuspended(u.ID) if err != nil { log.Error("view articles: %v", err) } d := struct { *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection Suspended bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, Suspended: suspended, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT") showUserPage(w, "articles", d) return nil } func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error { c, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) return fmt.Errorf("No collections") } f, _ := getSessionFlashes(app, w, r, nil) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors suspended, err := app.db.IsUserSuspended(u.ID) if err != nil { log.Error("view collections %v", err) return fmt.Errorf("view collections: %v", err) } d := struct { *UserPage Collections *[]Collection UsedCollections, TotalCollections int NewBlogsDisabled bool Suspended bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), Suspended: suspended, } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) return nil } func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) c, err := app.db.GetCollection(vars["collection"]) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } suspended, err := app.db.IsUserSuspended(u.ID) if err != nil { log.Error("view edit collection %v", err) return fmt.Errorf("view edit collection: %v", err) } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage *Collection Suspended bool }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, Suspended: suspended, } showUserPage(w, "collection", obj) return nil } func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) var s userSettings var u *User var sess *sessions.Session var err error if reqJSON { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } u, err = app.db.GetAPIUser(accessToken) if err != nil { return ErrBadAccessToken } decoder := json.NewDecoder(r.Body) err := decoder.Decode(&s) if err != nil { log.Error("Couldn't parse settings JSON request: %v\n", err) return ErrBadJSON } // Prevent all username updates // TODO: support changing username via JSON API request s.Username = "" } else { u, sess = getUserAndSession(app, r) if u == nil { return ErrNotLoggedIn } err := r.ParseForm() if err != nil { log.Error("Couldn't parse settings form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&s, r.PostForm) if err != nil { log.Error("Couldn't decode settings form request: %v\n", err) return ErrBadFormData } } // Do update postUpdateReturn := r.FormValue("return") redirectTo := "/me/settings" if s.IsLogOut { redirectTo += "?logout=1" } else if postUpdateReturn != "" { redirectTo = postUpdateReturn } // Only do updates on values we need if s.Username != "" && s.Username == u.Username { // Username hasn't actually changed; blank it out s.Username = "" } err = app.db.ChangeSettings(app, u, &s) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } } else { // Successful update. if reqJSON { return impart.WriteSuccess(w, u, http.StatusOK) } if s.IsLogOut { redirectTo = "/me/logout" } else { sess.Values[cookieUserVal] = u.Cookie() addSessionFlash(app, w, r, "Account updated.", nil) } } w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } curPass := r.FormValue("current") newPass := r.FormValue("new") // Ensure a new password is given (always required) if newPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide a new password."} } userID, sudo := app.db.GetUserIDPrivilege(accessToken) if userID == -1 { return ErrBadAccessToken } // Ensure a current password is given if the access token doesn't have sudo // privileges. if !sudo && curPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide current password."} } // Hash the new password hashedPass, err := auth.HashPass([]byte(newPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Do update err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass) if err != nil { return err } return impart.WriteSuccess(w, struct{}{}, http.StatusOK) } func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error { var c *Collection var err error vars := mux.Vars(r) alias := vars["collection"] if alias != "" { c, err = app.db.GetCollection(alias) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } } topPosts, err := app.db.GetTopPosts(u, alias) if err != nil { log.Error("Unable to get top posts: %v", err) return err } flashes, _ := getSessionFlashes(app, w, r, nil) titleStats := "" if c != nil { titleStats = c.DisplayTitle() + " " } suspended, err := app.db.IsUserSuspended(u.ID) if err != nil { log.Error("view stats: %v", err) return err } obj := struct { *UserPage VisitsBlog string Collection *Collection TopPosts *[]PublicPost APFollowers int Suspended bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, Suspended: suspended, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { return err } obj.APFollowers = len(*folls) } showUserPage(w, "stats", obj) return nil } func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { fullUser, err := app.db.GetUserByID(u.ID) if err != nil { log.Error("Unable to get user for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { log.Error("Unable to get isUserPassSet for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage Email string HasPass bool IsLogOut bool Suspended bool }{ UserPage: NewUserPage(app, r, u, "Account Settings", flashes), Email: fullUser.EmailClear(app.keys), HasPass: passIsSet, IsLogOut: r.FormValue("logout") == "1", Suspended: fullUser.IsSilenced(), } showUserPage(w, "settings", obj) return nil } func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error { session, err := app.sessionStore.Get(r, "t") if err != nil { return ErrInternalCookieSession } session.Values[key] = val err = session.Save(r, w) if err != nil { log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err) } return err } func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string { session, err := app.sessionStore.Get(r, "t") if err != nil { return "" } // Get the information var s = "" var ok bool if s, ok = session.Values[key].(string); !ok { return "" } // Delete cookie session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't erase temp data for key %s: %v", key, err) } // Return value return s } + +func prepareUserEmail(input string, emailKey []byte) zero.String { + email := zero.NewString("", input != "") + if len(input) > 0 { + encEmail, err := data.Encrypt(emailKey, input) + if err != nil { + log.Error("Unable to encrypt email: %s\n", err) + } else { + email.String = string(encEmail) + } + } + return email +} diff --git a/oauth.go b/oauth.go index 7dfc4c7..f9d9e99 100644 --- a/oauth.go +++ b/oauth.go @@ -1,246 +1,244 @@ package writefreely import ( "context" "encoding/json" "fmt" "github.com/gorilla/mux" "github.com/gorilla/sessions" - "github.com/guregu/null/zero" "github.com/writeas/impart" "github.com/writeas/nerds/store" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "io" "io/ioutil" "net/http" "time" ) // TokenResponse contains data returned when a token is created either // through a code exchange or using a refresh token. type TokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` Error string `json:"error"` } // InspectResponse contains data returned when an access token is inspected. type InspectResponse struct { ClientID string `json:"client_id"` UserID string `json:"user_id"` ExpiresAt time.Time `json:"expires_at"` Username string `json:"username"` DisplayName string `json:"-"` Email string `json:"email"` Error string `json:"error"` } // tokenRequestMaxLen is the most bytes that we'll read from the /oauth/token // endpoint. One megabyte is plenty. const tokenRequestMaxLen = 1000000 // infoRequestMaxLen is the most bytes that we'll read from the // /oauth/inspect endpoint. const infoRequestMaxLen = 1000000 // OAuthDatastoreProvider provides a minimal interface of data store, config, // and session store for use with the oauth handlers. type OAuthDatastoreProvider interface { DB() OAuthDatastore Config() *config.Config SessionStore() sessions.Store } // OAuthDatastore provides a minimal interface of data store methods used in // oauth functionality. type OAuthDatastore interface { GetIDForRemoteUser(context.Context, string, string, string) (int64, error) RecordRemoteUserID(context.Context, int64, string, string, string, string) error ValidateOAuthState(context.Context, string) (string, string, error) GenerateOAuthState(context.Context, string, string) (string, error) CreateUser(*config.Config, *User, string) error GetUserForAuthByID(int64) (*User, error) } type HttpClient interface { Do(req *http.Request) (*http.Response, error) } type oauthClient interface { GetProvider() string GetClientID() string buildLoginURL(state string) (string, error) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) } type oauthHandler struct { Config *config.Config DB OAuthDatastore Store sessions.Store + EmailKey []byte oauthClient oauthClient } func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error { ctx := r.Context() state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID()) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } location, err := h.oauthClient.buildLoginURL(state) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } return impart.HTTPError{http.StatusTemporaryRedirect, location} } func configureSlackOauth(parentHandler *Handler, r *mux.Router, app *App) { if app.Config().SlackOauth.ClientID != "" { oauthClient := slackOauthClient{ ClientID: app.Config().SlackOauth.ClientID, ClientSecret: app.Config().SlackOauth.ClientSecret, TeamID: app.Config().SlackOauth.TeamID, CallbackLocation: app.Config().App.Host + "/oauth/callback", HttpClient: config.DefaultHTTPClient(), } configureOauthRoutes(parentHandler, r, app, oauthClient) } } func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) { if app.Config().WriteAsOauth.ClientID != "" { oauthClient := writeAsOauthClient{ ClientID: app.Config().WriteAsOauth.ClientID, ClientSecret: app.Config().WriteAsOauth.ClientSecret, ExchangeLocation: config.OrDefaultString(app.Config().WriteAsOauth.TokenLocation, writeAsExchangeLocation), InspectLocation: config.OrDefaultString(app.Config().WriteAsOauth.InspectLocation, writeAsIdentityLocation), AuthLocation: config.OrDefaultString(app.Config().WriteAsOauth.AuthLocation, writeAsAuthLocation), HttpClient: config.DefaultHTTPClient(), CallbackLocation: app.Config().App.Host + "/oauth/callback", - } - if oauthClient.ExchangeLocation == "" { - } configureOauthRoutes(parentHandler, r, app, oauthClient) } } func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient) { handler := &oauthHandler{ Config: app.Config(), DB: app.DB(), Store: app.SessionStore(), oauthClient: oauthClient, + EmailKey: app.keys.EmailKey, } r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET") r.HandleFunc("/oauth/callback", parentHandler.OAuth(handler.viewOauthCallback)).Methods("GET") } func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http.Request) error { ctx := r.Context() code := r.FormValue("code") state := r.FormValue("state") provider, clientID, err := h.DB.ValidateOAuthState(ctx, state) if err != nil { log.Error("Unable to ValidateOAuthState: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code) if err != nil { log.Error("Unable to exchangeOauthCode: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } // Now that we have the access token, let's use it real quick to make sur // it really really works. tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken) if err != nil { log.Error("Unable to inspectOauthAccessToken: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } localUserID, err := h.DB.GetIDForRemoteUser(ctx, tokenInfo.UserID, provider, clientID) if err != nil { log.Error("Unable to GetIDForRemoteUser: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } if localUserID == -1 { // We don't have, nor do we want, the password from the origin, so we //create a random string. If the user needs to set a password, they //can do so through the settings page or through the password reset //flow. randPass := store.Generate62RandomString(14) hashedPass, err := auth.HashPass([]byte(randPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "unable to create password hash"} } newUser := &User{ Username: tokenInfo.Username, HashedPass: hashedPass, HasPass: true, - Email: zero.NewString(tokenInfo.Email, tokenInfo.Email != ""), + Email: prepareUserEmail(tokenInfo.Email, h.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } displayName := tokenInfo.DisplayName if len(displayName) == 0 { displayName = tokenInfo.Username } err = h.DB.CreateUser(h.Config, newUser, displayName) if err != nil { return impart.HTTPError{http.StatusInternalServerError, err.Error()} } err = h.DB.RecordRemoteUserID(ctx, newUser.ID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken) if err != nil { return impart.HTTPError{http.StatusInternalServerError, err.Error()} } if err := loginOrFail(h.Store, w, r, newUser); err != nil { return impart.HTTPError{http.StatusInternalServerError, err.Error()} } return nil } user, err := h.DB.GetUserForAuthByID(localUserID) if err != nil { return impart.HTTPError{http.StatusInternalServerError, err.Error()} } if err = loginOrFail(h.Store, w, r, user); err != nil { return impart.HTTPError{http.StatusInternalServerError, err.Error()} } return nil } func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error { lr := io.LimitReader(body, int64(n+1)) data, err := ioutil.ReadAll(lr) if err != nil { return err } if len(data) == n+1 { return fmt.Errorf("content larger than max read allowance: %d", n) } return json.Unmarshal(data, thing) } func loginOrFail(store sessions.Store, w http.ResponseWriter, r *http.Request, user *User) error { // An error may be returned, but a valid session should always be returned. session, _ := store.Get(r, cookieName) session.Values[cookieUserVal] = user.Cookie() if err := session.Save(r, w); err != nil { fmt.Println("error saving session", err) return err } http.Redirect(w, r, "/", http.StatusTemporaryRedirect) return nil } diff --git a/oauth_test.go b/oauth_test.go index 1daabd5..f8ffcf5 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -1,250 +1,253 @@ package writefreely import ( "context" "fmt" "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/writeas/impart" "github.com/writeas/nerds/store" "github.com/writeas/writefreely/config" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) type MockOAuthDatastoreProvider struct { DoDB func() OAuthDatastore DoConfig func() *config.Config DoSessionStore func() sessions.Store } type MockOAuthDatastore struct { DoGenerateOAuthState func(context.Context, string, string) (string, error) DoValidateOAuthState func(context.Context, string) (string, string, error) DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error) DoCreateUser func(*config.Config, *User, string) error DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error DoGetUserForAuthByID func(int64) (*User, error) } var _ OAuthDatastore = &MockOAuthDatastore{} type StringReadCloser struct { *strings.Reader } func (src *StringReadCloser) Close() error { return nil } type MockHTTPClient struct { DoDo func(req *http.Request) (*http.Response, error) } func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { if m.DoDo != nil { return m.DoDo(req) } return &http.Response{}, nil } func (m *MockOAuthDatastoreProvider) SessionStore() sessions.Store { if m.DoSessionStore != nil { return m.DoSessionStore() } return sessions.NewCookieStore([]byte("secret-key")) } func (m *MockOAuthDatastoreProvider) DB() OAuthDatastore { if m.DoDB != nil { return m.DoDB() } return &MockOAuthDatastore{} } func (m *MockOAuthDatastoreProvider) Config() *config.Config { if m.DoConfig != nil { return m.DoConfig() } cfg := config.New() cfg.UseSQLite(true) cfg.WriteAsOauth = config.WriteAsOauthCfg{ ClientID: "development", ClientSecret: "development", AuthLocation: "https://write.as/oauth/login", TokenLocation: "https://write.as/oauth/token", InspectLocation: "https://write.as/oauth/inspect", } cfg.SlackOauth = config.SlackOauthCfg{ ClientID: "development", ClientSecret: "development", TeamID: "development", } return cfg } func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, error) { if m.DoValidateOAuthState != nil { return m.DoValidateOAuthState(ctx, state) } return "", "", nil } func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) { if m.DoGetIDForRemoteUser != nil { return m.DoGetIDForRemoteUser(ctx, remoteUserID, provider, clientID) } return -1, nil } func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username string) error { if m.DoCreateUser != nil { return m.DoCreateUser(cfg, u, username) } u.ID = 1 return nil } func (m *MockOAuthDatastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error { if m.DoRecordRemoteUserID != nil { return m.DoRecordRemoteUserID(ctx, localUserID, remoteUserID, provider, clientID, accessToken) } return nil } func (m *MockOAuthDatastore) GetUserForAuthByID(userID int64) (*User, error) { if m.DoGetUserForAuthByID != nil { return m.DoGetUserForAuthByID(userID) } user := &User{ } return user, nil } func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string) (string, error) { if m.DoGenerateOAuthState != nil { return m.DoGenerateOAuthState(ctx, provider, clientID) } return store.Generate62RandomString(14), nil } func TestViewOauthInit(t *testing.T) { t.Run("success", func(t *testing.T) { app := &MockOAuthDatastoreProvider{} h := oauthHandler{ Config: app.Config(), DB: app.DB(), Store: app.SessionStore(), + EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd}, oauthClient: writeAsOauthClient{ ClientID: app.Config().WriteAsOauth.ClientID, ClientSecret: app.Config().WriteAsOauth.ClientSecret, ExchangeLocation: app.Config().WriteAsOauth.TokenLocation, InspectLocation: app.Config().WriteAsOauth.InspectLocation, AuthLocation: app.Config().WriteAsOauth.AuthLocation, CallbackLocation: "http://localhost/oauth/callback", HttpClient: nil, }, } req, err := http.NewRequest("GET", "/oauth/client", nil) assert.NoError(t, err) rr := httptest.NewRecorder() err = h.viewOauthInit(nil, rr, req) assert.NotNil(t, err) httpErr, ok := err.(impart.HTTPError) assert.True(t, ok) assert.Equal(t, http.StatusTemporaryRedirect, httpErr.Status) assert.NotEmpty(t, httpErr.Message) locURI, err := url.Parse(httpErr.Message) assert.NoError(t, err) assert.Equal(t, "/oauth/login", locURI.Path) assert.Equal(t, "development", locURI.Query().Get("client_id")) assert.Equal(t, "http://localhost/oauth/callback", locURI.Query().Get("redirect_uri")) assert.Equal(t, "code", locURI.Query().Get("response_type")) assert.NotEmpty(t, locURI.Query().Get("state")) }) t.Run("state failure", func(t *testing.T) { app := &MockOAuthDatastoreProvider{ DoDB: func() OAuthDatastore { return &MockOAuthDatastore{ DoGenerateOAuthState: func(ctx context.Context, provider, clientID string) (string, error) { return "", fmt.Errorf("pretend unable to write state error") }, } }, } h := oauthHandler{ Config: app.Config(), DB: app.DB(), Store: app.SessionStore(), + EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd}, oauthClient: writeAsOauthClient{ ClientID: app.Config().WriteAsOauth.ClientID, ClientSecret: app.Config().WriteAsOauth.ClientSecret, ExchangeLocation: app.Config().WriteAsOauth.TokenLocation, InspectLocation: app.Config().WriteAsOauth.InspectLocation, AuthLocation: app.Config().WriteAsOauth.AuthLocation, CallbackLocation: "http://localhost/oauth/callback", HttpClient: nil, }, } req, err := http.NewRequest("GET", "/oauth/client", nil) assert.NoError(t, err) rr := httptest.NewRecorder() err = h.viewOauthInit(nil, rr, req) httpErr, ok := err.(impart.HTTPError) assert.True(t, ok) assert.NotEmpty(t, httpErr.Message) assert.Equal(t, http.StatusInternalServerError, httpErr.Status) assert.Equal(t, "could not prepare oauth redirect url", httpErr.Message) }) } func TestViewOauthCallback(t *testing.T) { t.Run("success", func(t *testing.T) { app := &MockOAuthDatastoreProvider{} h := oauthHandler{ Config: app.Config(), DB: app.DB(), Store: app.SessionStore(), + EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd}, oauthClient: writeAsOauthClient{ ClientID: app.Config().WriteAsOauth.ClientID, ClientSecret: app.Config().WriteAsOauth.ClientSecret, ExchangeLocation: app.Config().WriteAsOauth.TokenLocation, InspectLocation: app.Config().WriteAsOauth.InspectLocation, AuthLocation: app.Config().WriteAsOauth.AuthLocation, CallbackLocation: "http://localhost/oauth/callback", HttpClient: &MockHTTPClient{ DoDo: func(req *http.Request) (*http.Response, error) { switch req.URL.String() { case "https://write.as/oauth/token": return &http.Response{ StatusCode: 200, Body: &StringReadCloser{strings.NewReader(`{"access_token": "access_token", "expires_in": 1000, "refresh_token": "refresh_token", "token_type": "access"}`)}, }, nil case "https://write.as/oauth/inspect": return &http.Response{ StatusCode: 200, Body: &StringReadCloser{strings.NewReader(`{"client_id": "development", "user_id": "1", "expires_at": "2019-12-19T11:42:01Z", "username": "nick", "email": "nick@testing.write.as"}`)}, }, nil } return &http.Response{ StatusCode: http.StatusNotFound, }, nil }, }, }, } req, err := http.NewRequest("GET", "/oauth/callback", nil) assert.NoError(t, err) rr := httptest.NewRecorder() - h.viewOauthCallback(nil, rr, req) + err = h.viewOauthCallback(nil, rr, req) assert.NoError(t, err) assert.Equal(t, http.StatusTemporaryRedirect, rr.Code) }) }