package app import ( "crypto/rand" "fmt" "log" "net/http" "slices" "strconv" "strings" "time" ) const sessionCookieName = "go14_session" const sessionTokenHashPrefix = "__auth_token_@#!@235DRFT455f!" const sessionTokenHashSuffix = "---@@!!321sdFD344#$@$#HNvb32D12@" const sessionExpire = 2592000 const sessionTokenTypeName = "session" /* Generate an ID that represents user client (IP address + user-agent), not guaranteed to be unique, but hopefully unique enough. */ func getUserClientID(r *http.Request) string { userIP := r.Header.Get("X-Forwarded-For") if userIP == "" { userIP = r.RemoteAddr } userIP = strings.Split(userIP, ":")[0] userAgent := r.Header.Get("User-Agent") return hashString(userIP + ":" + userAgent) } /* Get identifier for current user, if logged in then use their user ID, otherise their client ID. */ func getUserID(r *http.Request) string { user, _ := getCurrentUserFromSession(r) if user.ID > 0 { return fmt.Sprintf("user:%d", user.ID) } return "client:" + getUserClientID(r) } /* Generate a session token. */ func generateUserSessionToken(r *http.Request) string { clientID := getUserClientID(r) return hashString(sessionTokenHashPrefix+clientID+rand.Text()+formatTime(time.Now())+clientID+sessionTokenHashSuffix) + rand.Text() } /* Generate session cookie from auth record. */ func generateUserSessionCookie(userAuthToken *UserAuthTokenRecord) http.Cookie { cookieToken := fmt.Sprintf("%d:%s", userAuthToken.ID, hashString(fmt.Sprintf("-!--%d%s%d%s", userAuthToken.ID, userAuthToken.Token, userAuthToken.UserID, formatTime(userAuthToken.CreatedAt)))) return http.Cookie{Name: sessionCookieName, Value: cookieToken, Expires: userAuthToken.ExpiresAt, Path: "/"} } /* Get current user. */ func getCurrentUserFromSession(r *http.Request) (UserRecord, error) { // retrieve session cookie cookie, err := r.Cookie(sessionCookieName) if err != nil { return UserRecord{}, err } // read auth token id from cookie value authTokenID, err := strconv.ParseInt(strings.Split(cookie.Value, ":")[0], 10, 64) if err != nil { return UserRecord{}, err } // fetch auth token record userAuthToken, err := fetchUserAuthTokenRecordByID(db, authTokenID) if err != nil { return UserRecord{}, err } // compare cookie value to auth token if cookie.Value != generateUserSessionCookie(&userAuthToken).Value { return UserRecord{}, errAuthError } // fetch user record log.Printf(" >> logged in as user %d", userAuthToken.UserID) return fetchUserRecordByID(db, userAuthToken.UserID) } func (u UserRecord) HasID(ids []int64) bool { return slices.Contains(ids, u.ID) } func httpHandleLoginRedirect(w http.ResponseWriter, r *http.Request) { redirectURL := r.URL.Query().Get("redirect") urlSplit := strings.Split(redirectURL, "://") redirectURL = "/" + urlSplit[len(urlSplit)-1] http.Redirect(w, r, redirectURL, http.StatusFound) } func httpCreateUserSession(w http.ResponseWriter, r *http.Request, userID int64) error { // clean up expired tokens if err := deleteUserAuthTokenRecordsByType(db, userID, sessionTokenTypeName); err != nil { return err } if err := deleteExpiredUserAuthTokenRecords(db, userID); err != nil { return err } // create session auth token record userAuthToken := UserAuthTokenRecord{ UserID: userID, ExpiresAt: time.Now().Add(time.Second * sessionExpire), Type: sessionTokenTypeName, ClientID: getUserClientID(r), Token: generateUserSessionToken(r), } if err := createUserAuthTokenRecord(db, &userAuthToken); err != nil { return err } // set cookie cookie := generateUserSessionCookie(&userAuthToken) http.SetCookie(w, &cookie) return nil } func httpHandleUserLogin(w http.ResponseWriter, r *http.Request) { // get existing user user, err := getCurrentUserFromSession(r) // create new user if existing not found if err != nil { if err := createUserRecord(db, &user); err != nil { httpHandleError(w, err) return } } // create session if err := httpCreateUserSession(w, r, user.ID); err != nil { httpHandleError(w, err) return } // redirect httpHandleLoginRedirect(w, r) } func httpHandleUserLogout(w http.ResponseWriter, r *http.Request) { user, err := getCurrentUserFromSession(r) if err != nil { http.Redirect(w, r, "/", http.StatusFound) return } deleteUserAuthTokenRecordsByType(db, user.ID, sessionTokenTypeName) http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Expires: time.Now().Add(-time.Minute), Path: "/"}) http.Redirect(w, r, "/", http.StatusFound) } func httpHandleUserPage(w http.ResponseWriter, r *http.Request) { // fetch current user user, err := getCurrentUserFromSession(r) if err != nil || user.ID == 0 { httpHandleError(w, errAuthError) return } // use placeholder username if not set username := user.Username if username == "" { username = fmt.Sprintf("User%d", user.ID) } // handle characters characters, err := fetchCharacterRecordsByUserID(db, user.ID) if err != nil { httpHandleError(w, err) return } if r.Method == "POST" { r.ParseForm() for _, character := range characters { if r.Form.Has(fmt.Sprintf("main_%d", character.ID)) { log.Printf(" >> set character %d as main", character.ID) for i := range characters { characters[i].IsMain = characters[i].ID == character.ID updateCharacterRecord(db, &characters[i]) } } } } characterData := make([]map[string]any, 0) for _, character := range characters { status := strings.ReplaceAll(character.Status, "_", " ") name := fmt.Sprintf("%s %s %s", character.FirstName, character.LastName, character.World) characterData = append(characterData, map[string]any{ "id": character.ID, "name": name, "avatarURL": getCharacterAvatarURL(character.ID), "status": status, "lodestoneURL": lodestoneCharacterURLPrefix + character.LodestoneID, "isMain": character.IsMain, "verifyURL": getAddCharacterURL(character.ID), "deleteURL": getDeleteCharacterURL(character.ID), }) } // handle documents documents, err := fetchDocumentRecordsByUserID(db, user.ID) if err != nil { httpHandleError(w, err) return } documentList := make([]map[string]any, 0) for _, document := range documents { options := [][]string{ {"View", getDocumentURL(document.ID)}, {"Edit", getDocumentEditURL(document.ID)}, {"Delete", getDocumentDeleteURL(document.ID)}, } documentList = append(documentList, map[string]any{ "id": document.ID, "url": getDocumentURL(document.ID), "name": document.Title, "updatedAt": formatHumanTime(document.UpdatedAt), "options": options, }) } // serve template httpHandleTemplate(w, "user.html", map[string]any{ "user": user, "username": username, "characters": characterData, "documents": documentList, "newCharacterURL": getAddCharacterURL(0), "newDocumentURL": getDocumentEditURL(0), }) } func httpBaseHandleUser(w http.ResponseWriter, r *http.Request, path []string) bool { if path[0] == "u" || path[0] == "user" { // sub pages if len(path) > 1 && path[1] != "" { switch path[1] { case "login": httpHandleUserLogin(w, r) case "logout": httpHandleUserLogout(w, r) case "discord": httpHandleDiscordLogin(w, r) default: httpHandleError(w, errHttpNotFound) } return true } httpHandleUserPage(w, r) return true } return false }