2022-08-10 05:10:47 +00:00
|
|
|
// 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"
|
2022-08-10 05:10:47 +00:00
|
|
|
"time"
|
|
|
|
|
2022-08-26 04:21:46 +00:00
|
|
|
"code.nonshy.com/nonshy/website/pkg/config"
|
|
|
|
"code.nonshy.com/nonshy/website/pkg/log"
|
2022-12-25 07:00:59 +00:00
|
|
|
"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"
|
2022-08-10 05:10:47 +00:00
|
|
|
"github.com/google/uuid"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Session cookie object that is kept server side in Redis.
|
|
|
|
type Session struct {
|
2022-08-14 23:27:57 +00:00
|
|
|
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"`
|
2022-08-10 05:10:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const (
|
2022-08-21 21:17:52 +00:00
|
|
|
ContextKey = "session"
|
|
|
|
CurrentUserKey = "current_user"
|
|
|
|
CSRFKey = "csrf"
|
2024-03-04 01:58:18 +00:00
|
|
|
RequestTimeKey = "req_time"
|
2022-08-10 05:10:47 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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)
|
2022-08-22 00:29:39 +00:00
|
|
|
// log.Error("LoadOrNew: raw from Redis: %+v", sess)
|
2022-08-10 05:10:47 +00:00
|
|
|
if err != nil {
|
2022-09-27 02:41:07 +00:00
|
|
|
log.Error("session.LoadOrNew: didn't find %s in Redis: %s", key, err)
|
2022-08-10 05:10:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
2022-08-14 23:27:57 +00:00
|
|
|
Path: "/",
|
2022-08-10 05:10:47 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +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)
|
2022-10-21 04:51:53 +00:00
|
|
|
sess.Errors = append(sess.Errors, fmt.Sprintf(msg, args...))
|
2022-08-10 05:10:47 +00:00
|
|
|
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
|
2022-08-14 23:27:57 +00:00
|
|
|
sess.Impersonator = 0
|
2022-08-10 05:10:47 +00:00
|
|
|
sess.Save(w)
|
|
|
|
|
2022-08-11 03:59:59 +00:00
|
|
|
// Ping the user's last login time.
|
2024-09-12 02:28:52 +00:00
|
|
|
return u.PingLastLoginAt()
|
2022-08-10 05:10:47 +00:00
|
|
|
}
|
|
|
|
|
2022-08-14 23:27:57 +00:00
|
|
|
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
2022-12-25 07:00:59 +00:00
|
|
|
func ImpersonateUser(w http.ResponseWriter, r *http.Request, u *models.User, impersonator *models.User, reason string) error {
|
2022-08-14 23:27:57 +00:00
|
|
|
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)
|
|
|
|
|
2022-12-25 07:00:59 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2022-08-14 23:27:57 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-08-14 23:27:57 +00:00
|
|
|
return sess.Impersonator > 0
|
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
// 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)
|
|
|
|
}
|