website/pkg/session/session.go

247 lines
6.6 KiB
Go
Raw Normal View History

// Package session handles user login and other cookies.
package session
import (
"errors"
"fmt"
"net/http"
2022-09-27 02:41:07 +00:00
"regexp"
2022-09-27 02:12:24 +00:00
"strings"
"time"
2022-08-26 04:21:46 +00:00
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail"
2022-08-26 04:21:46 +00:00
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis"
"github.com/google/uuid"
)
// Session cookie object that is kept server side in Redis.
type Session struct {
UUID string `json:"-"` // not stored
LoggedIn bool `json:"loggedIn"`
UserID uint64 `json:"userId,omitempty"`
Flashes []string `json:"flashes,omitempty"`
Errors []string `json:"errors,omitempty"`
Impersonator uint64 `json:"impersonator,omitempty"`
LastSeen time.Time `json:"lastSeen"`
}
const (
ContextKey = "session"
CurrentUserKey = "current_user"
CSRFKey = "csrf"
)
// New creates a blank session object.
func New() *Session {
return &Session{
UUID: uuid.New().String(),
Flashes: []string{},
Errors: []string{},
}
}
// Load the session from the browser session_id token and Redis or creates a new session.
func LoadOrNew(r *http.Request) *Session {
var sess = New()
// Read the session cookie value.
cookie, err := r.Cookie(config.SessionCookieName)
if err != nil {
log.Debug("session.LoadOrNew: cookie error, new sess: %s", err)
return sess
}
// Look up this UUID in Redis.
sess.UUID = cookie.Value
key := fmt.Sprintf(config.SessionRedisKeyFormat, sess.UUID)
err = redis.Get(key, sess)
// log.Error("LoadOrNew: raw from Redis: %+v", sess)
if err != nil {
2022-09-27 02:41:07 +00:00
log.Error("session.LoadOrNew: didn't find %s in Redis: %s", key, err)
}
return sess
}
// Save the session and send a cookie header.
func (s *Session) Save(w http.ResponseWriter) {
// Roll a UUID session_id value.
if s.UUID == "" {
s.UUID = uuid.New().String()
}
// Ensure it is a valid UUID.
if _, err := uuid.Parse(s.UUID); err != nil {
log.Error("Session.Save: got an invalid UUID session_id: %s", err)
s.UUID = uuid.New().String()
}
// Ping last seen.
s.LastSeen = time.Now()
// Save their session object in Redis.
key := fmt.Sprintf(config.SessionRedisKeyFormat, s.UUID)
if err := redis.Set(key, s, config.SessionCookieMaxAge*time.Second); err != nil {
log.Error("Session.Save: couldn't write to Redis: %s", err)
}
cookie := &http.Cookie{
Name: config.SessionCookieName,
Value: s.UUID,
MaxAge: config.SessionCookieMaxAge,
Path: "/",
HttpOnly: true,
}
http.SetCookie(w, cookie)
}
// Get the session from the current HTTP request context.
func Get(r *http.Request) *Session {
if r == nil {
panic("session.Get: http.Request is required")
}
ctx := r.Context()
if sess, ok := ctx.Value(ContextKey).(*Session); ok {
return sess
}
// If the session isn't on the request, it means I broke something.
log.Error("session.Get(): didn't find session in request context!")
return nil
}
2022-09-27 02:41:07 +00:00
var portSuffixRegexp = regexp.MustCompile(`:(\d+)$`)
2022-09-27 02:12:24 +00:00
// RemoteAddr returns the user's remote IP address. If UseXForwardedFor is enabled in settings.json,
// the HTTP header X-Forwarded-For may be returned here or otherwise the request RemoteAddr is returned.
func RemoteAddr(r *http.Request) string {
2022-09-27 02:41:07 +00:00
var remoteAddr = r.RemoteAddr // Usually "ip:port" format
2022-09-27 02:12:24 +00:00
if config.Current.UseXForwardedFor {
xff := r.Header.Get("X-Forwarded-For")
if len(xff) > 0 {
2022-09-27 02:41:07 +00:00
remoteAddr = strings.SplitN(xff, ",", 2)[0]
2022-09-27 02:12:24 +00:00
}
}
2022-09-27 02:41:07 +00:00
// Return just the IP and not the port suffix.
return portSuffixRegexp.ReplaceAllString(remoteAddr, "")
2022-09-27 02:12:24 +00:00
}
// ReadFlashes returns and clears the Flashes and Errors for this session.
func (s *Session) ReadFlashes(w http.ResponseWriter) (flashes, errors []string) {
flashes = s.Flashes
errors = s.Errors
s.Flashes = []string{}
s.Errors = []string{}
if len(flashes)+len(errors) > 0 {
s.Save(w)
}
return flashes, errors
}
// Flash adds a transient message to the user's session to show on next page load.
func Flash(w http.ResponseWriter, r *http.Request, msg string, args ...interface{}) {
sess := Get(r)
sess.Flashes = append(sess.Flashes, fmt.Sprintf(msg, args...))
sess.Save(w)
}
// FlashError adds a transient error message to the session.
func FlashError(w http.ResponseWriter, r *http.Request, msg string, args ...interface{}) {
sess := Get(r)
sess.Errors = append(sess.Errors, fmt.Sprintf(msg, args...))
sess.Save(w)
}
// LoginUser marks a session as logged in to an account.
func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
if u == nil || u.ID == 0 {
return errors.New("not a valid user account")
}
sess := Get(r)
sess.LoggedIn = true
sess.UserID = u.ID
sess.Impersonator = 0
sess.Save(w)
// Ping the user's last login time.
u.LastLoginAt = time.Now()
return u.Save()
}
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
func ImpersonateUser(w http.ResponseWriter, r *http.Request, u *models.User, impersonator *models.User, reason string) error {
if u == nil || u.ID == 0 {
return errors.New("not a valid user account")
}
if impersonator == nil || impersonator.ID == 0 || !impersonator.IsAdmin {
return errors.New("impersonator not a valid admin account")
}
sess := Get(r)
sess.LoggedIn = true
sess.UserID = u.ID
sess.Impersonator = impersonator.ID
sess.Save(w)
// Issue an admin notification that this has happened.
// NOTE: not DRY compared to contact.go
fb := &models.Feedback{
Intent: "report",
Subject: "'Impersonate user' has been used",
TableName: "users",
TableID: impersonator.ID,
Message: fmt.Sprintf(
"The admin user **%s** (id:%d) has impersonated user **%s** (id:%d)\n\n"+
"The reason they have given:\n\n%s",
impersonator.Username, impersonator.ID,
u.Username, u.ID, reason,
),
}
if err := models.CreateFeedback(fb); err != nil {
FlashError(w, r, "Couldn't create admin notification: %s", err)
}
// Email the admins.
if err := mail.Send(mail.Message{
To: config.Current.AdminEmail,
Subject: "Admin 'user impersonate' has been used",
Template: "email/admin_impersonate.html",
Data: map[string]interface{}{
"Impersonator": impersonator,
"User": u,
"Reason": reason,
"AdminURL": config.Current.BaseURL + "/admin/feedback",
},
}); err != nil {
log.Error("/contact page: couldn't send email: %s", err)
}
return u.Save()
}
// Impersonated returns if the current session has an impersonator.
func Impersonated(r *http.Request) bool {
sess := Get(r)
2022-09-27 02:46:05 +00:00
if sess == nil {
return false
}
return sess.Impersonator > 0
}
// LogoutUser signs a user out.
func LogoutUser(w http.ResponseWriter, r *http.Request) {
sess := Get(r)
sess.LoggedIn = false
sess.UserID = 0
sess.Save(w)
}