diff --git a/account.go b/account.go index 9e6542f..e5648c0 100644 --- a/account.go +++ b/account.go @@ -1,1557 +1,1566 @@ /* * Copyright © 2018-2021 Musing Studio 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" "strconv" "strings" "sync" "time" "github.com/gorilla/csrf" "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/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/mailer" "github.com/writefreely/writefreely/page" "github.com/writefreely/writefreely/spam" ) 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 CollAlias string } ) const maxPassByteLen = 72 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) { if app.cfg.App.DisablePasswordAuth { err := ErrDisabledPasswordAuth return nil, err } 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) + // Signup checks are enforced here to keep them from being bypassed on different endpoints. + if app.cfg.App.DisablePasswordAuth { + return nil, ErrDisabledPasswordAuth + } + // Closed registration requires a valid, active invite code. + if err := app.canRegister(signup.InviteCode); err != nil { + return nil, err + } + // 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 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: true, Email: prepareUserEmail(signup.Email, app.keys.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } // Create actual user if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil { return nil, err } // Log invite if needed if signup.InviteCode != "" { err = app.db.CreateInvitedUser(signup.InviteCode, u.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, } title := signup.Alias if signup.Normalize { title = desiredUsername } resUser.Collections = &[]Collection{ { Alias: signup.Alias, Title: title, Description: signup.Description, }, } var coll *Collection if signup.Monetization != "" { if coll == nil { coll, err = app.db.GetCollection(signup.Alias) if err != nil { log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err) return nil, err } } err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization) if err != nil { log.Error("Unable to add monetization on signup: %v", err) return nil, err } coll.Monetization = signup.Monetization } 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 *OAuthButtons To string Message template.HTML Flashes []template.HTML EmailEnabled bool LoginUsername string }{ StaticPage: pageForReq(app, r), OAuthButtons: NewOAuthButtons(app.Config()), To: r.FormValue("to"), Message: template.HTML(""), Flashes: []template.HTML{}, EmailEnabled: app.cfg.Email.Enabled(), LoginUsername: 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 if app.cfg.App.DisablePasswordAuth { err := ErrDisabledPasswordAuth return err } // 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 into %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 len(u.HashedPass) == 0 { return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"} } 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 message 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 := struct { *UserPage }{ UserPage: 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 } isAnonPosts := r.FormValue("anonymous") == "1" if isAnonPosts { pageStr := r.FormValue("page") pg, err := strconv.Atoi(pageStr) if err != nil { log.Error("Error parsing page parameter '%s': %s", pageStr, err) pg = 1 } p, err := app.db.GetAnonymousPosts(u, pg) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } 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, 1) 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) } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view articles: %v", err) } d := struct { *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, Silenced: silenced, } 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 silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view collections: %v", err) return fmt.Errorf("view collections: %v", err) } d := struct { *UserPage Collections *[]Collection UsedCollections, TotalCollections int NewBlogsDisabled bool Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), Silenced: silenced, } 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 } c.hostName = app.cfg.App.Host if c.OwnerID != u.ID { return ErrCollectionNotFound } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } 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 Silenced bool config.EmailCfg LetterReplyTo string }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, Silenced: silenced, EmailCfg: app.cfg.Email, } obj.UserPage.CollAlias = c.Alias if obj.EmailCfg.Enabled() { obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo) } 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 } c.hostName = app.cfg.App.Host } topPosts, err := app.db.GetTopPosts(u, alias, c.hostName) 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() + " " } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view stats: %v", err) return err } obj := struct { *UserPage VisitsBlog string Collection *Collection TopPosts *[]PublicPost APFollowers int EmailEnabled bool EmailSubscribers int Silenced bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, EmailEnabled: app.cfg.Email.Enabled(), Silenced: silenced, } obj.UserPage.CollAlias = c.Alias if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { return err } obj.APFollowers = len(*folls) } if obj.EmailEnabled { subs, err := app.db.GetEmailSubscribers(c.ID, true) if err != nil { return err } obj.EmailSubscribers = len(subs) } showUserPage(w, "stats", obj) return nil } func handleViewSubscribers(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 u.ID != c.OwnerID { return ErrCollectionNotFound } filter := r.FormValue("filter") flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage Collection CollectionNav EmailSubs []*EmailSubscriber Followers *[]RemoteUser Silenced bool Filter string FederationEnabled bool CanEmailSub bool CanAddSubs bool EmailSubsEnabled bool }{ UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes), Collection: CollectionNav{ Collection: c, Path: r.URL.Path, SingleUser: app.cfg.App.SingleUser, }, Silenced: u.IsSilenced(), Filter: filter, FederationEnabled: app.cfg.App.Federation, CanEmailSub: app.cfg.Email.Enabled(), EmailSubsEnabled: c.EmailSubsEnabled(), } obj.Followers, err = app.db.GetAPFollowers(c) if err != nil { return err } obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true) if err != nil { return err } if obj.Filter == "" { // Set permission to add email subscribers //obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1" } showUserPage(w, "subscribers", 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 { if err == ErrUserNotFound { return err } 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) enableOauthSlack := app.Config().SlackOauth.ClientID != "" enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != "" enableOauthGitLab := app.Config().GitlabOauth.ClientID != "" enableOauthGeneric := app.Config().GenericOauth.ClientID != "" enableOauthGitea := app.Config().GiteaOauth.ClientID != "" oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID) if err != nil { log.Error("Unable to get oauth accounts for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } for idx, oauthAccount := range oauthAccounts { switch oauthAccount.Provider { case "slack": enableOauthSlack = false case "write.as": enableOauthWriteAs = false case "gitlab": enableOauthGitLab = false case "generic": oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect enableOauthGeneric = false case "gitea": enableOauthGitea = false } } displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0 obj := struct { *UserPage Email string HasPass bool IsLogOut bool Silenced bool CSRFField template.HTML OauthSection bool OauthAccounts []oauthAccountInfo OauthSlack bool OauthWriteAs bool OauthGitLab bool GitLabDisplayName string OauthGeneric bool OauthGenericDisplayName string OauthGitea bool GiteaDisplayName string }{ UserPage: NewUserPage(app, r, u, "Account Settings", flashes), Email: fullUser.EmailClear(app.keys), HasPass: passIsSet, IsLogOut: r.FormValue("logout") == "1", Silenced: fullUser.IsSilenced(), CSRFField: csrf.TemplateField(r), OauthSection: displayOauthSection, OauthAccounts: oauthAccounts, OauthSlack: enableOauthSlack, OauthWriteAs: enableOauthWriteAs, OauthGitLab: enableOauthGitLab, GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), OauthGeneric: enableOauthGeneric, OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), OauthGitea: enableOauthGitea, GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), } showUserPage(w, "settings", obj) return nil } func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error { token := r.FormValue("t") resetting := false var userID int64 = 0 if token != "" { // Show new password page userID = app.db.GetUserFromPasswordReset(token) if userID == 0 { return impart.HTTPError{http.StatusNotFound, ""} } resetting = true } if r.Method == http.MethodPost { newPass := r.FormValue("new-pass") if newPass == "" { // Send password reset email return handleResetPasswordInit(app, w, r) } // Do actual password reset // Assumes token has been validated above err := doAutomatedPasswordChange(app, userID, newPass) if err != nil { return err } err = app.db.ConsumePasswordResetToken(token) if err != nil { log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err) } addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil) return impart.HTTPError{http.StatusFound, "/login"} } f, _ := getSessionFlashes(app, w, r, nil) // Show reset password page d := struct { page.StaticPage Flashes []string EmailEnabled bool CSRFField template.HTML Token string IsResetting bool IsSent bool }{ StaticPage: pageForReq(app, r), Flashes: f, EmailEnabled: app.cfg.Email.Enabled(), CSRFField: csrf.TemplateField(r), Token: token, IsResetting: resetting, IsSent: r.FormValue("sent") == "1", } err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d) if err != nil { log.Error("Unable to render password reset page: %v", err) return err } return err } func doAutomatedPasswordChange(app *App, userID int64, newPass string) error { // Do password reset 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, true, "", hashedPass) if err != nil { return err } return nil } func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error { returnLoc := impart.HTTPError{http.StatusFound, "/reset"} if !app.cfg.Email.Enabled() { // Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation return returnLoc } ip := spam.GetIP(r) alias := r.FormValue("alias") u, err := app.db.GetUserForAuth(alias) if err != nil { if strings.IndexAny(alias, "@") > 0 { addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil) return returnLoc } addSessionFlash(app, w, r, ErrUserNotFound.Message, nil) return returnLoc } if u.IsAdmin() { // Prevent any reset emails on admin accounts log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip) return returnLoc } if u.Email.String == "" { err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."} addSessionFlash(app, w, r, err.Message, nil) return returnLoc } if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet { err = loginViaEmail(app, u.Username, "/me/settings") if err != nil { return err } addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil) return returnLoc } token, err := app.db.CreatePasswordResetToken(u.ID) if err != nil { log.Error("Error resetting password: %s", err) addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil) return returnLoc } err = emailPasswordReset(app, u.EmailClear(app.keys), token) if err != nil { log.Error("Error emailing password reset: %s", err) addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil) return returnLoc } addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil) returnLoc.Message += "?sent=1" return returnLoc } func emailPasswordReset(app *App, toEmail, token string) error { // Send email mlr, err := mailer.New(app.cfg.Email) if err != nil { return err } footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email." plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara) m, err := mlr.NewMessage(mailer.FormatAddress(app.cfg.App.SiteName, "noreply-password@"+app.cfg.Email.Domain), "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail)) if err != nil { return err } m.AddTag("Password Reset") m.SetHTML(fmt.Sprintf(`

%s

We received a request to reset your password on %s. Please click the following link to continue:

Reset your password

%s

`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)) return mlr.Send(m) } func loginViaEmail(app *App, alias, redirectTo string) error { if !app.cfg.Email.Enabled() { return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server") } // Make sure user has added an email // TODO: create a new func to just get user's email; "ForAuth" doesn't match here u, _ := app.db.GetUserForAuth(alias) if u == nil { if strings.IndexAny(alias, "@") > 0 { return ErrUserNotFoundEmail } return ErrUserNotFound } if u.Email.String == "" { return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."} } // Generate one-time login token t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true) if err != nil { log.Error("Unable to generate token for email login: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."} } // Send email mlr, err := mailer.New(app.cfg.Email) if err != nil { return err } toEmail := u.EmailClear(app.keys) footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email." plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara) m, err := mlr.NewMessage(mailer.FormatAddress(app.cfg.App.SiteName, "noreply-login@"+app.cfg.Email.Domain), "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail)) if err != nil { return err } m.AddTag("Email Login") m.SetHTML(fmt.Sprintf(`

%s

Log in to %s here.

%s

`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara)) return mlr.Send(m) } 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 handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error { if !app.cfg.App.OpenDeletion { return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."} } confirmUsername := r.PostFormValue("confirm-username") if u.Username != confirmUsername { return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."} } // Check for account deletion safeguards in place if u.IsAdmin() { return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."} } err := app.db.DeleteAccount(u.ID) if err != nil { log.Error("user delete account: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)} } // FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset _ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil) return impart.HTTPError{http.StatusFound, "/me/logout"} } func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error { provider := r.FormValue("provider") clientID := r.FormValue("client_id") remoteUserID := r.FormValue("remote_user_id") err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID) if err != nil { return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()} } return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"} } 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_signup.go b/oauth_signup.go index 1608de7..1f41d12 100644 --- a/oauth_signup.go +++ b/oauth_signup.go @@ -1,239 +1,250 @@ /* * Copyright © 2020-2021 Musing Studio 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 ( "crypto/sha256" "encoding/hex" "fmt" "html/template" "net/http" "strings" "time" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/page" ) type viewOauthSignupVars struct { page.StaticPage To string Message template.HTML Flashes []template.HTML AccessToken string TokenUsername string TokenAlias string // TODO: rename this to match the data it represents: the collection title TokenEmail string TokenRemoteUser string Provider string ClientID string TokenHash string InviteCode string LoginUsername string Alias string // TODO: rename this to match the data it represents: the collection title Email string } const ( oauthParamAccessToken = "access_token" oauthParamTokenUsername = "token_username" oauthParamTokenAlias = "token_alias" oauthParamTokenEmail = "token_email" oauthParamTokenRemoteUserID = "token_remote_user" oauthParamClientID = "client_id" oauthParamProvider = "provider" oauthParamHash = "signature" oauthParamUsername = "username" oauthParamAlias = "alias" oauthParamEmail = "email" oauthParamPassword = "password" oauthParamInviteCode = "invite_code" ) type oauthSignupPageParams struct { AccessToken string TokenUsername string TokenAlias string // TODO: rename this to match the data it represents: the collection title TokenEmail string TokenRemoteUser string ClientID string Provider string TokenHash string InviteCode string } func (p oauthSignupPageParams) HashTokenParams(key string) string { hasher := sha256.New() hasher.Write([]byte(key)) hasher.Write([]byte(p.AccessToken)) hasher.Write([]byte(p.TokenUsername)) hasher.Write([]byte(p.TokenAlias)) hasher.Write([]byte(p.TokenEmail)) hasher.Write([]byte(p.TokenRemoteUser)) hasher.Write([]byte(p.ClientID)) hasher.Write([]byte(p.Provider)) + // Include the invite code so it can't be swapped out between the callback (where it was validated) and this + // completion step. + hasher.Write([]byte(p.InviteCode)) return hex.EncodeToString(hasher.Sum(nil)) } func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.Request) error { tp := &oauthSignupPageParams{ AccessToken: r.FormValue(oauthParamAccessToken), TokenUsername: r.FormValue(oauthParamTokenUsername), TokenAlias: r.FormValue(oauthParamTokenAlias), TokenEmail: r.FormValue(oauthParamTokenEmail), TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID), ClientID: r.FormValue(oauthParamClientID), Provider: r.FormValue(oauthParamProvider), InviteCode: r.FormValue(oauthParamInviteCode), } if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."} } tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed) + // Re-check registration eligibility at the completion step rather than + // trusting that the callback already did. The signed hash proves the OAuth + // identity params (including the invite code) weren't tampered with, but + // the account is only actually created here -- so this is where closed + // registration and invite validity must be enforced. + if err := app.canRegister(tp.InviteCode); err != nil { + return h.showOauthSignupPage(app, w, r, tp, err) + } if err := h.validateOauthSignup(r); err != nil { return h.showOauthSignupPage(app, w, r, tp, err) } var err error hashedPass := []byte{} clearPass := r.FormValue(oauthParamPassword) hasPass := clearPass != "" if hasPass { hashedPass, err = auth.HashPass([]byte(clearPass)) if err != nil { return h.showOauthSignupPage(app, w, r, tp, fmt.Errorf("unable to hash password")) } } newUser := &User{ // Normalize the username into a valid slug, just like normal registration, so the collection alias is always // lowercase Username: getSlug(r.FormValue(oauthParamUsername), ""), HashedPass: hashedPass, HasPass: hasPass, Email: prepareUserEmail(r.FormValue(oauthParamEmail), h.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } displayName := r.FormValue(oauthParamAlias) if len(displayName) == 0 { displayName = r.FormValue(oauthParamUsername) } err = h.DB.CreateUser(h.Config, newUser, displayName, "") if err != nil { return h.showOauthSignupPage(app, w, r, tp, err) } // Log invite if needed if tp.InviteCode != "" { err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID) if err != nil { return err } } err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken)) if err != nil { return h.showOauthSignupPage(app, w, r, tp, err) } if err := loginOrFail(h.Store, w, r, newUser); err != nil { return h.showOauthSignupPage(app, w, r, tp, err) } return nil } func (h oauthHandler) validateOauthSignup(r *http.Request) error { // Validate the normalized username, since that's what we'll actually store as the user's name and collection alias username := getSlug(r.FormValue(oauthParamUsername), "") if len(username) < h.Config.App.MinUsernameLen { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too short."} } if len(username) > 100 { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too long."} } if !author.IsValidUsername(h.Config, username) { return impart.HTTPError{Status: http.StatusPreconditionFailed, Message: "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."} } collTitle := r.FormValue(oauthParamAlias) if len(collTitle) == 0 { collTitle = username } email := r.FormValue(oauthParamEmail) if len(email) > 0 { parts := strings.Split(email, "@") if len(parts) != 2 || (len(parts[0]) < 1 || len(parts[1]) < 1) { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Invalid email address"} } } return nil } func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *http.Request, tp *oauthSignupPageParams, errMsg error) error { username := tp.TokenUsername collTitle := tp.TokenAlias email := tp.TokenEmail session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session; ignoring: %v", err) } if tmpValue := r.FormValue(oauthParamUsername); len(tmpValue) > 0 { username = tmpValue } if tmpValue := r.FormValue(oauthParamAlias); len(tmpValue) > 0 { collTitle = tmpValue } if tmpValue := r.FormValue(oauthParamEmail); len(tmpValue) > 0 { email = tmpValue } p := &viewOauthSignupVars{ StaticPage: pageForReq(app, r), To: r.FormValue("to"), Flashes: []template.HTML{}, AccessToken: tp.AccessToken, TokenUsername: tp.TokenUsername, TokenAlias: tp.TokenAlias, TokenEmail: tp.TokenEmail, TokenRemoteUser: tp.TokenRemoteUser, Provider: tp.Provider, ClientID: tp.ClientID, TokenHash: tp.TokenHash, InviteCode: tp.InviteCode, LoginUsername: username, Alias: collTitle, Email: email, } // Display any error messages flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } if errMsg != nil { p.Flashes = append(p.Flashes, template.HTML(errMsg.Error())) } err = pages["signup-oauth.tmpl"].ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render signup-oauth: %v", err) return err } return nil } diff --git a/routes.go b/routes.go index f8979cd..29e240a 100644 --- a/routes.go +++ b/routes.go @@ -1,265 +1,263 @@ /* * Copyright © 2018-2021 Musing Studio 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 ( "net/http" "net/url" "path/filepath" "strings" "github.com/gorilla/csrf" "github.com/gorilla/mux" "github.com/writeas/go-webfinger" "github.com/writeas/web-core/log" "github.com/writefreely/go-nodeinfo" ) // InitStaticRoutes adds routes for serving static files. // TODO: this should just be a func, not method func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) fs = cacheControl(fs) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) } // InitRoutes adds dynamic routes for the given mux.Router. func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Create handler handler := NewWFHandler(apper) // Set up routes hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:] if apper.App().cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if apper.App().cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.PathPrefix("/").Subrouter() // Federation endpoint configurations wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg}) wf.NoTLSHandler = nil // Federation endpoints // host-meta write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader)) // webfinger write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger))) // nodeinfo niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // handle mentions write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader)) configureSlackOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App()) configureGitlabOauth(handler, write, apper.App()) configureGenericOauth(handler, write, apper.App()) configureGiteaOauth(handler, write, apper.App()) // Set up dynamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() - if apper.App().cfg.App.OpenRegistration { - auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") - } + auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") auth.HandleFunc("/login", handler.All(login)).Methods("POST") auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST") auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE") // Handle logged in user sections me := write.PathPrefix("/me").Subrouter() me.HandleFunc("/", handler.Redirect("/me", UserLevelUser)) me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") me.HandleFunc("/c/{collection}/subscribers", handler.User(handleViewSubscribers)).Methods("GET") me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleUserDelete))).Methods("POST") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.Path("/import").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(viewImport))).Methods("GET") me.Path("/settings").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(viewSettings))).Methods("GET") me.Path("/invites").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleViewUserInvites))).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") apiMe := write.PathPrefix("/api/me/").Subrouter() apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET") apiMe.HandleFunc("/posts", handler.UserWebAPI(viewMyPostsAPI)).Methods("GET") apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.Path("/self").Handler(csrfProtectForm(apper.App().keys.CSRFKey, handler.All(updateSettings))).Methods("POST") apiMe.Path("/invites").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleCreateUserInvite))).Methods("POST") apiMe.Path("/import").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleImport))).Methods("POST") apiMe.Path("/oauth/remove").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(removeOauth))).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST") instanceURL, _ := url.Parse(apper.App().Config().App.Host) host := instanceURL.Host // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls.HandleFunc("/monetization-pointer", handler.PlainTextAPI(handleSPSPEndpoint)).Methods("GET") apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}/splitcontent", handler.AllReader(handleGetSplitContent)).Methods("GET", "POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/email/subscribers/export.csv", handler.Download(handleExportEmailSubscriptions, UserLevelUser)).Methods("GET") apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST") apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE") apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET") // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.AllReader(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]+}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET") write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST") write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST") write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") // Handle special pages first write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.Web(viewResetPassword, UserLevelNoneRequired))) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } else { write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } // All the existing stuff write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) } else { write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader)) write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader)) RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) // Posts } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) return r } // csrfProtectForm wraps a handler with CSRF protection but exempts JSON // requests, which authenticate via Authorization header rather than // session cookies and are therefore not CSRF-exploitable. func csrfProtectForm(key []byte, next http.Handler) http.Handler { protected := csrf.Protect(key, csrf.Path("/"))(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if IsJSON(r) { next.ServeHTTP(w, r) return } protected.ServeHTTP(w, r) }) } func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional)) r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/archive/", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/page/{page:[0-9]+}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET") r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET") r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET") } func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) { r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/feed", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) } diff --git a/signup_test.go b/signup_test.go index ab7d223..1b48ab2 100644 --- a/signup_test.go +++ b/signup_test.go @@ -1,440 +1,481 @@ //go:build sqlite /* * Copyright © 2026 Musing Studio 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 ( "database/sql" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync" "testing" "time" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/stretchr/testify/assert" "github.com/writeas/impart" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/key" ) var initTemplatesOnce sync.Once // newSignupTestApp builds a real, sqlite-backed App suitable for exercising // the HTTP signup handlers end-to-end. It's intentionally minimal: only the // pieces those handlers actually touch (db, cfg, keys, session store, form // decoder, and the global page/template caches) are initialized. func newSignupTestApp(t *testing.T) *App { t.Helper() dbPath := filepath.Join(t.TempDir(), "writefreely.db") db, err := sql.Open("sqlite3_with_regex", dbPath+"?parseTime=true&cached=shared") if err != nil { t.Fatalf("open test db: %v", err) } t.Cleanup(func() { db.Close() }) cfg := config.New() cfg.UseSQLite(true) cfg.Database.FileName = dbPath cfg.App.Host = "http://localhost:0" cfg.App.SingleUser = false cfg.App.OpenRegistration = false cfg.App.MinUsernameLen = 3 cfg.Server.HashSeed = "test-hash-seed" keys := &key.Keychain{} if err := keys.GenerateKeys(); err != nil { t.Fatalf("generate keys: %v", err) } app := &App{ db: &datastore{DB: db, driverName: driverSQLite}, cfg: cfg, keys: keys, } app.formDecoder = schema.NewDecoder() app.InitSession() if err := adminInitDatabase(app); err != nil { t.Fatalf("init schema: %v", err) } var tmplErr error initTemplatesOnce.Do(func() { tmplErr = InitTemplates(cfg) }) if tmplErr != nil { t.Fatalf("init templates: %v", tmplErr) } return app } // seedInvite inserts a directly-usable invite code owned by ownerID, with no // expiration or use limit. func seedInvite(t *testing.T, app *App, code string, ownerID int64) { t.Helper() _, err := app.db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, NULL, ?, NULL, 0)", code, ownerID, time.Now().UTC().Format("2006-01-02 15:04:05")) if err != nil { t.Fatalf("seed invite: %v", err) } } func userExists(t *testing.T, app *App, username string) bool { t.Helper() var id int64 err := app.db.QueryRow("SELECT id FROM users WHERE username = ?", username).Scan(&id) if err == sql.ErrNoRows { return false } if err != nil { t.Fatalf("query user: %v", err) } return true } // --- Regression coverage for #1722: POST /auth/signup (handleWebSignup) --- // No test previously covered this path even though the fix landed there. func TestWebSignupClosedRegistration(t *testing.T) { cases := []struct { name string invite string wantStatus int wantUser bool }{ {"no invite code", "", http.StatusForbidden, false}, {"bogus invite code", "not-a-real-code", http.StatusForbidden, false}, {"valid invite code", "webvalid1", http.StatusFound, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = false if tc.invite == "webvalid1" { seedInvite(t, app, "webvalid1", 0) } username := "websignup-" + strings.ReplaceAll(tc.name, " ", "-") form := url.Values{} form.Set("alias", username) form.Set("pass", "sup3rSecret!") form.Set("invite_code", tc.invite) req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() err := handleWebSignup(app, w, req) // handleWebSignup always returns an impart.HTTPError: a 302 to // "to" on success, or the rejection status on failure. httpErr, ok := err.(impart.HTTPError) if !ok { t.Fatalf("expected impart.HTTPError, got %T (%v)", err, err) } if httpErr.Status != tc.wantStatus { t.Errorf("status = %d, want %d", httpErr.Status, tc.wantStatus) } assert.Equal(t, tc.wantUser, userExists(t, app, username), "unexpected account-creation outcome for case %q", tc.name) }) } } // --- Finding A: POST /api/auth/signup (apiSignup -> signup) --- func TestAPISignupClosedRegistration(t *testing.T) { cases := []struct { name string invite string wantStatus int wantUser bool }{ {"no invite code", "", http.StatusForbidden, false}, {"bogus invite code", "not-a-real-code", http.StatusForbidden, false}, {"valid invite code", "valid1", http.StatusFound /* signupWithRegistration succeeds */, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = false if tc.invite == "valid1" { seedInvite(t, app, "valid1", 0) } username := "apisignup-" + strings.ReplaceAll(tc.name, " ", "-") form := url.Values{} form.Set("alias", username) form.Set("pass", "sup3rSecret!") form.Set("invite_code", tc.invite) req := httptest.NewRequest("POST", "/api/auth/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() err := apiSignup(app, w, req) if !tc.wantUser { httpErr, ok := err.(impart.HTTPError) if !ok { t.Fatalf("expected impart.HTTPError, got %T (%v)", err, err) } if httpErr.Status != tc.wantStatus { t.Errorf("status = %d, want %d", httpErr.Status, tc.wantStatus) } } else if err != nil { t.Fatalf("expected signup to succeed, got error: %v", err) } assert.Equal(t, tc.wantUser, userExists(t, app, username), "unexpected account-creation outcome for case %q", tc.name) }) } } func TestAPISignupOpenRegistrationStillWorks(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = true form := url.Values{} form.Set("alias", "apisignup-open") form.Set("pass", "sup3rSecret!") req := httptest.NewRequest("POST", "/api/auth/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() if err := apiSignup(app, w, req); err != nil { t.Fatalf("expected signup to succeed with open registration, got: %v", err) } assert.True(t, userExists(t, app, "apisignup-open")) } +// TestSignupDisabledPasswordAuth guards against creating a password-based +// account when password auth is disabled. +func TestSignupDisabledPasswordAuth(t *testing.T) { + newReq := func(path, alias string) (*http.Request, *httptest.ResponseRecorder) { + form := url.Values{} + form.Set("alias", alias) + form.Set("pass", "sup3rSecret!") + req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req, httptest.NewRecorder() + } + + t.Run("api", func(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = true + app.cfg.App.DisablePasswordAuth = true + + req, w := newReq("/api/auth/signup", "pwauth-api") + if err := apiSignup(app, w, req); err != ErrDisabledPasswordAuth { + t.Fatalf("expected ErrDisabledPasswordAuth, got: %v", err) + } + assert.False(t, userExists(t, app, "pwauth-api")) + }) + + t.Run("web", func(t *testing.T) { + app := newSignupTestApp(t) + app.cfg.App.OpenRegistration = true + app.cfg.App.DisablePasswordAuth = true + + req, w := newReq("/auth/signup", "pwauth-web") + // handleWebSignup converts the rejection into a flash + 302 redirect + // rather than returning the error verbatim, so the real signal is that + // no account was created. + err := handleWebSignup(app, w, req) + if httpErr, ok := err.(impart.HTTPError); !ok || httpErr.Status != http.StatusFound { + t.Fatalf("expected 302 redirect, got: %v", err) + } + assert.False(t, userExists(t, app, "pwauth-web")) + }) +} + // TestInitRoutesAlwaysRegistersAPISignup guards against the routing-time // bypass in Finding A: /api/auth/signup used to only be registered when // OpenRegistration was true *at boot*, so toggling the setting off at // runtime (via the admin panel) left the route live with no invite check // at all. It must now always resolve, regardless of the setting. func TestInitRoutesAlwaysRegistersAPISignup(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = false router := InitRoutes(app, mux.NewRouter()) req := httptest.NewRequest("POST", "/api/auth/signup", nil) var match mux.RouteMatch if !router.Match(req, &match) { t.Fatal("expected /api/auth/signup to match a route even when OpenRegistration is false") } } // --- Finding B: POST /oauth/signup (viewOauthSignup) --- func newTestOauthHandler(app *App) oauthHandler { return oauthHandler{ Config: app.cfg, DB: app.db, Store: app.sessionStore, EmailKey: app.keys.EmailKey, } } func signOauthParams(hashSeed string, p oauthSignupPageParams) string { return p.HashTokenParams(hashSeed) } func TestOAuthSignupClosedRegistration(t *testing.T) { cases := []struct { name string invite string wantUser bool }{ {"no invite code", "", false}, {"bogus invite code", "not-a-real-code", false}, {"valid invite code", "oauthok", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = false h := newTestOauthHandler(app) if tc.invite == "oauthok" { seedInvite(t, app, "oauthok", 0) } username := "oauthsignup-" + strings.ReplaceAll(tc.name, " ", "-") tp := oauthSignupPageParams{ AccessToken: "tok-" + username, TokenUsername: username, TokenAlias: username, TokenEmail: "", TokenRemoteUser: "remote-" + username, ClientID: "client1", Provider: "generic", InviteCode: tc.invite, } sig := signOauthParams(app.cfg.Server.HashSeed, tp) form := url.Values{} form.Set(oauthParamAccessToken, tp.AccessToken) form.Set(oauthParamTokenUsername, tp.TokenUsername) form.Set(oauthParamTokenAlias, tp.TokenAlias) form.Set(oauthParamTokenEmail, tp.TokenEmail) form.Set(oauthParamTokenRemoteUserID, tp.TokenRemoteUser) form.Set(oauthParamClientID, tp.ClientID) form.Set(oauthParamProvider, tp.Provider) form.Set(oauthParamInviteCode, tp.InviteCode) form.Set(oauthParamHash, sig) form.Set(oauthParamUsername, username) req := httptest.NewRequest("POST", "/oauth/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() err := h.viewOauthSignup(app, w, req) // On rejection, viewOauthSignup re-renders the signup-oauth page // inline with a flash message rather than returning an // impart.HTTPError (mirroring showOauthSignupPage's contract), so // the account-creation outcome in the DB is the real signal here. if !tc.wantUser { if err != nil { t.Fatalf("expected inline re-render (nil error), got: %v", err) } if !strings.Contains(w.Body.String(), "Registration is closed") { t.Errorf("expected rejection message in response body, got: %s", w.Body.String()) } } else if err != nil { t.Fatalf("expected oauth signup to succeed, got error: %v", err) } assert.Equal(t, tc.wantUser, userExists(t, app, username), "unexpected account-creation outcome for case %q", tc.name) }) } } // TestOAuthSignupCannotSwapInviteCodeWithoutInvalidatingSignature proves the // replay bypass from Finding B is closed: a signature minted for one // invite_code (or none) can no longer be reused with a different invite_code // value, since InviteCode is now part of the signed payload. func TestOAuthSignupCannotSwapInviteCodeWithoutInvalidatingSignature(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = true // signature minted while registration was open h := newTestOauthHandler(app) username := "oauthsignup-swap" tp := oauthSignupPageParams{ AccessToken: "tok-swap", TokenUsername: username, TokenAlias: username, TokenRemoteUser: "remote-swap", ClientID: "client1", Provider: "generic", InviteCode: "", // signed with an empty invite code } sig := signOauthParams(app.cfg.Server.HashSeed, tp) // Registration is closed by the time the (replayed) form is submitted. app.cfg.App.OpenRegistration = false form := url.Values{} form.Set(oauthParamAccessToken, tp.AccessToken) form.Set(oauthParamTokenUsername, tp.TokenUsername) form.Set(oauthParamTokenAlias, tp.TokenAlias) form.Set(oauthParamTokenRemoteUserID, tp.TokenRemoteUser) form.Set(oauthParamClientID, tp.ClientID) form.Set(oauthParamProvider, tp.Provider) form.Set(oauthParamInviteCode, "swapped-in-bogus-code") // tampered after signing form.Set(oauthParamHash, sig) form.Set(oauthParamUsername, username) req := httptest.NewRequest("POST", "/oauth/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() err := h.viewOauthSignup(app, w, req) httpErr, ok := err.(impart.HTTPError) if !ok { t.Fatalf("expected impart.HTTPError, got %T (%v)", err, err) } if httpErr.Status != http.StatusBadRequest { t.Errorf("status = %d, want %d (tampered request)", httpErr.Status, http.StatusBadRequest) } assert.False(t, userExists(t, app, username)) } // TestOAuthSignupNormalizesUsername is regression coverage for #648 and #844: // usernames created via OAuth must be run through the same slug normalization as // normal registration, so the resulting collection alias is always lowercase. // Otherwise the blog 404s, since collection URLs are redirected to their // lowercase form on request but no matching (lowercase) alias exists. func TestOAuthSignupNormalizesUsername(t *testing.T) { app := newSignupTestApp(t) app.cfg.App.OpenRegistration = true h := newTestOauthHandler(app) const submitted = "MixedCaseUser" const normalized = "mixedcaseuser" tp := oauthSignupPageParams{ AccessToken: "tok-mixed", TokenUsername: submitted, TokenAlias: submitted, TokenRemoteUser: "remote-mixed", ClientID: "client1", Provider: "generic", } sig := signOauthParams(app.cfg.Server.HashSeed, tp) form := url.Values{} form.Set(oauthParamAccessToken, tp.AccessToken) form.Set(oauthParamTokenUsername, tp.TokenUsername) form.Set(oauthParamTokenAlias, tp.TokenAlias) form.Set(oauthParamTokenRemoteUserID, tp.TokenRemoteUser) form.Set(oauthParamClientID, tp.ClientID) form.Set(oauthParamProvider, tp.Provider) form.Set(oauthParamHash, sig) form.Set(oauthParamUsername, submitted) req := httptest.NewRequest("POST", "/oauth/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() if err := h.viewOauthSignup(app, w, req); err != nil { t.Fatalf("expected oauth signup to succeed, got error: %v", err) } assert.True(t, userExists(t, app, normalized), "user should be stored with a lowercased username") assert.False(t, userExists(t, app, submitted), "user should not be stored with the raw mixed-case username") var alias string err := app.db.QueryRow("SELECT alias FROM collections WHERE alias = ?", normalized).Scan(&alias) if err != nil { t.Fatalf("expected collection with lowercased alias %q: %v", normalized, err) } assert.Equal(t, normalized, alias, "collection alias must be lowercase to be reachable") } diff --git a/unregisteredusers.go b/unregisteredusers.go index d3b0141..c7690bd 100644 --- a/unregisteredusers.go +++ b/unregisteredusers.go @@ -1,161 +1,170 @@ /* * Copyright © 2018-2019 Musing Studio 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 ( "database/sql" "encoding/json" "net/http" "github.com/writeas/impart" "github.com/writeas/web-core/log" ) +// canRegister reports whether a new account may be created given the instance's registration setting and the given +// invite code. When registration is open, anyone may sign up. When it's closed, a valid, still-active invite code is +// required -- otherwise any non-empty value (or none at all) would be enough to create an account. This is the single +// gate every signup path (web, API, and OAuth) must pass through so the checks can't be side-stepped by hitting a +// different endpoint. +func (app *App) canRegister(inviteCode string) error { + if app.cfg.App.OpenRegistration { + return nil + } + i, err := app.db.GetUserInvite(inviteCode) + if err != nil { + return impart.HTTPError{http.StatusForbidden, "Registration is closed"} + } + if !i.Active(app.db) { + return impart.HTTPError{http.StatusNotFound, "Invite link has expired."} + } + return nil +} + func handleWebSignup(app *App, w http.ResponseWriter, r *http.Request) 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 ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse signup form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&ur, r.PostForm) if err != nil { log.Error("Couldn't decode signup form request: %v\n", err) return ErrBadFormData } } - if !app.cfg.App.OpenRegistration { - // Registrations are closed, so an invite is required. Verify the given - // code actually exists and is still usable -- otherwise any non-empty - // value would be enough to create an account. Mirrors the check the - // OAuth signup flow already performs in viewOauthCallback(). - i, err := app.db.GetUserInvite(ur.InviteCode) - if err != nil { - return impart.HTTPError{http.StatusForbidden, "Registration is closed"} - } - if !i.Active(app.db) { - return impart.HTTPError{http.StatusNotFound, "Invite link has expired."} - } + if err := app.canRegister(ur.InviteCode); err != nil { + return err } ur.Web = true ur.Normalize = true to := "/" if app.cfg.App.SimpleNav { to = "/new" } if ur.InviteCode != "" { to = "/invite/" + ur.InviteCode } _, err := signupWithRegistration(app, ur, w, r) if err != nil { if err, ok := err.(impart.HTTPError); ok { session, _ := app.sessionStore.Get(r, cookieName) if session != nil { session.AddFlash(err.Message) session.Save(r, w) return impart.HTTPError{http.StatusFound, to} } } return err } return impart.HTTPError{http.StatusFound, to} } // { "username": "asdf" } // result: { code: 204 } func handleUsernameCheck(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) // Get params var d struct { Username string `json:"username"` } if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&d) if err != nil { log.Error("Couldn't decode username check: %v\n", err) return ErrBadFormData } } else { return impart.HTTPError{http.StatusNotAcceptable, "Must be JSON request"} } // Check if username is okay finalUsername := getSlug(d.Username, "") if finalUsername == "" { errMsg := "Invalid username" if d.Username != "" { // Username was provided, but didn't convert into valid latin characters errMsg += " - must have at least 2 letters or numbers" } return impart.HTTPError{http.StatusBadRequest, errMsg + "."} } if app.db.PostIDExists(finalUsername) { return impart.HTTPError{http.StatusConflict, "Username is already taken."} } var un string err := app.db.QueryRow("SELECT username FROM users WHERE username = ?", finalUsername).Scan(&un) switch { case err == sql.ErrNoRows: return impart.WriteSuccess(w, finalUsername, http.StatusOK) case err != nil: log.Error("Couldn't SELECT username: %v", err) return impart.HTTPError{http.StatusInternalServerError, "We messed up."} } // Username was found, so it's taken return impart.HTTPError{http.StatusConflict, "Username is already taken."} } func getValidUsername(app *App, reqName, prevName string) (string, *impart.HTTPError) { // Check if username is okay finalUsername := getSlug(reqName, "") if finalUsername == "" { errMsg := "Invalid username" if reqName != "" { // Username was provided, but didn't convert into valid latin characters errMsg += " - must have at least 2 letters or numbers" } return "", &impart.HTTPError{http.StatusBadRequest, errMsg + "."} } if finalUsername == prevName { return "", &impart.HTTPError{http.StatusNotModified, "Username unchanged."} } if app.db.PostIDExists(finalUsername) { return "", &impart.HTTPError{http.StatusConflict, "Username is already taken."} } var un string err := app.db.QueryRow("SELECT username FROM users WHERE username = ?", finalUsername).Scan(&un) switch { case err == sql.ErrNoRows: return finalUsername, nil case err != nil: log.Error("Couldn't SELECT username: %v", err) return "", &impart.HTTPError{http.StatusInternalServerError, "We messed up."} } // Username was found, so it's taken return "", &impart.HTTPError{http.StatusConflict, "Username is already taken."} }