Refactor session to use gorilla like sessions

This commit is contained in:
nemunaire 2024-05-21 17:05:53 +02:00
parent e99a604095
commit 3d9aecf214
16 changed files with 457 additions and 265 deletions

View File

@ -22,13 +22,11 @@
package admin
import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/config"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/storage"
)
@ -47,13 +45,7 @@ func deleteSessions(c *gin.Context) {
}
func sessionHandler(c *gin.Context) {
sessionid, err := base64.StdEncoding.DecodeString(c.Param("sessionid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()})
return
}
session, err := storage.MainStore.GetSession(sessionid)
session, err := storage.MainStore.GetSession(c.Param("sessionid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()})
return
@ -65,13 +57,9 @@ func sessionHandler(c *gin.Context) {
}
func getSession(c *gin.Context) {
session := c.MustGet("session").(*happydns.Session)
c.JSON(http.StatusOK, session)
c.JSON(http.StatusOK, c.MustGet("session"))
}
func deleteSession(c *gin.Context) {
session := c.MustGet("session").(*happydns.Session)
ApiResponse(c, true, storage.MainStore.DeleteSession(session))
ApiResponse(c, true, storage.MainStore.DeleteSession(c.Param("sessionid")))
}

View File

@ -25,9 +25,9 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
@ -50,8 +50,6 @@ type UserClaims struct {
jwt.RegisteredClaims
}
const COOKIE_NAME = "happydomain_session"
var signingMethod = jwt.SigningMethodHS512
func updateUserFromClaims(user *happydns.User, claims *UserClaims) {
@ -98,35 +96,6 @@ func retrieveUserFromClaims(claims *UserClaims) (user *happydns.User, err error)
return
}
func retrieveSessionFromClaims(claims *UserClaims, user *happydns.User, session_id []byte) (session *happydns.Session, err error) {
session, err = storage.MainStore.GetSession(session_id)
if err != nil {
// The session doesn't exists yet: create it!
session = &happydns.Session{
Id: session_id,
IdUser: claims.Profile.UserId,
IssuedAt: time.Now(),
}
err = storage.MainStore.UpdateSession(session)
if err != nil {
err = fmt.Errorf("has a correct JWT, but an error occured when trying to create the session: %w", err)
return
}
// Update user's data
updateUserFromClaims(user, claims)
err = storage.MainStore.UpdateUser(user)
if err != nil {
err = fmt.Errorf("has a correct JWT, session has been created, but an error occured when trying to update the user's information: %w", err)
return
}
}
return
}
func requireLogin(opts *config.Options, c *gin.Context, msg string) {
if opts.ExternalAuth.URL != nil {
customurl := *opts.ExternalAuth.URL
@ -141,17 +110,73 @@ func requireLogin(opts *config.Options, c *gin.Context, msg string) {
func authMiddleware(opts *config.Options, optional bool) gin.HandlerFunc {
return func(c *gin.Context) {
var token string
// Load user from session
session := sessions.Default(c)
// Retrieve the token from cookie or header
if cookie, err := c.Cookie(COOKIE_NAME); err == nil {
var userid happydns.Identifier
if iu, ok := session.Get("iduser").([]uint8); ok {
userid = happydns.Identifier(iu)
}
// Authentication through JWT
var token string
if c.GetHeader("X-User-Token") != "" {
token = c.GetHeader("X-User-Token")
} else if cookie, err := c.Cookie("happydomain-account"); err == nil {
token = cookie
} else if flds := strings.Fields(c.GetHeader("Authorization")); len(flds) == 2 && flds[0] == "Bearer" {
token = flds[1]
}
if len(opts.JWTSecretKey) > 0 && len(token) > 0 {
// Validate the token and retrieve claims
claims := &UserClaims{}
_, err := jwt.ParseWithClaims(token, claims,
func(token *jwt.Token) (interface{}, error) {
return []byte(opts.JWTSecretKey), nil
}, jwt.WithValidMethods([]string{signingMethod.Name}))
if err != nil {
if opts.NoAuth {
claims = displayNotAuthToken(opts, c)
}
log.Printf("%s provide a bad JWT claims: %s", c.ClientIP(), err.Error())
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
return
}
// Check that required fields are filled
if claims == nil || len(claims.Profile.UserId) == 0 {
log.Printf("%s: no UserId found in JWT claims", c.ClientIP())
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
return
}
if claims.Profile.Email == "" {
log.Printf("%s: no Email found in JWT claims", c.ClientIP())
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
return
}
// Retrieve corresponding user
user, err := retrieveUserFromClaims(claims)
userid = user.Id
if userid != nil {
if userid == nil || userid.IsEmpty() || !userid.Equals(user.Id) {
completeAuth(opts, c, claims.Profile)
session.Clear()
session.Set("iduser", user.Id)
err = session.Save()
if err != nil {
log.Printf("%s: unable to recreate session: %s", c.ClientIP(), err.Error())
requireLogin(opts, c, "Something went wrong with your session. Please contact your administrator.")
return
}
userid = user.Id
}
}
}
// Stop here if there is no cookie
if len(token) == 0 {
if userid == nil {
if optional {
c.Next()
} else {
@ -160,67 +185,16 @@ func authMiddleware(opts *config.Options, optional bool) gin.HandlerFunc {
return
}
// Validate the token and retrieve claims
claims := &UserClaims{}
_, err := jwt.ParseWithClaims(token, claims,
func(token *jwt.Token) (interface{}, error) {
return []byte(opts.JWTSecretKey), nil
}, jwt.WithValidMethods([]string{signingMethod.Name}))
if err != nil {
if opts.NoAuth {
claims = displayNotAuthToken(opts, c)
}
log.Printf("%s provide a bad JWT claims: %s", c.ClientIP(), err.Error())
c.SetCookie(COOKIE_NAME, "", -1, opts.BaseURL+"/", "", opts.DevProxy == "", true)
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
return
}
// Check that required fields are filled
if claims == nil || len(claims.Profile.UserId) == 0 {
log.Printf("%s: no UserId found in JWT claims", c.ClientIP())
c.SetCookie(COOKIE_NAME, "", -1, opts.BaseURL+"/", "", opts.DevProxy == "", true)
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
return
}
if claims.Profile.Email == "" {
log.Printf("%s: no Email found in JWT claims", c.ClientIP())
c.SetCookie(COOKIE_NAME, "", -1, opts.BaseURL+"/", "", opts.DevProxy == "", true)
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
return
}
// Retrieve corresponding user
user, err := retrieveUserFromClaims(claims)
user, err := storage.MainStore.GetUser(userid)
if err != nil {
log.Printf("%s %s", c.ClientIP(), err.Error())
c.SetCookie(COOKIE_NAME, "", -1, opts.BaseURL+"/", "", opts.DevProxy == "", true)
requireLogin(opts, c, "Something went wrong with your session. Please reconnect.")
requireLogin(opts, c, "Unable to retrieve your user. Please reauthenticate.")
return
}
c.Set("LoggedUser", user)
// Retrieve the session
session_id := append([]byte(claims.Profile.UserId), []byte(claims.ID)...)
session, err := retrieveSessionFromClaims(claims, user, session_id)
if err != nil {
log.Printf("%s %s", c.ClientIP(), err.Error())
c.SetCookie(COOKIE_NAME, "", -1, opts.BaseURL+"/", "", opts.DevProxy == "", true)
requireLogin(opts, c, "Your session has expired. Please reconnect.")
return
}
c.Set("MySession", session)
// We are now ready to continue
c.Next()
// On return, check if the session has changed
if session.HasChanged() {
storage.MainStore.UpdateSession(session)
}
}
}

View File

@ -22,6 +22,7 @@
package api
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/config"
@ -44,7 +45,7 @@ type FormState struct {
}
func formDoState(cfg *config.Options, c *gin.Context, fs *FormState, data interface{}, defaultForm func(interface{}) *forms.CustomForm) (form *forms.CustomForm, d map[string]interface{}, err error) {
session := c.MustGet("MySession").(*happydns.Session)
session := sessions.Default(c)
csf, ok := data.(forms.CustomSettingsForm)
if !ok {
@ -55,7 +56,7 @@ func formDoState(cfg *config.Options, c *gin.Context, fs *FormState, data interf
}
return
} else {
return csf.DisplaySettingsForm(fs.State, cfg, session, func() string {
return csf.DisplaySettingsForm(fs.State, cfg, &session, func() string {
return fs.Recall
})
}

View File

@ -22,17 +22,17 @@
package api
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"git.happydns.org/happyDomain/config"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/storage"
)
@ -51,7 +51,7 @@ func declareAuthenticationRoutes(opts *config.Options, router *gin.RouterGroup)
apiAuthRoutes.Use(authMiddleware(opts, true))
apiAuthRoutes.GET("", func(c *gin.Context) {
if _, exists := c.Get("MySession"); exists {
if _, exists := c.Get("LoggedUser"); exists {
displayAuthToken(c)
} else {
displayNotAuthToken(opts, c)
@ -140,7 +140,7 @@ func displayNotAuthToken(opts *config.Options, c *gin.Context) *UserClaims {
// @Router /auth/logout [post]
func logout(opts *config.Options, c *gin.Context) {
c.SetCookie(
COOKIE_NAME,
session.COOKIE_NAME,
"",
-1,
opts.BaseURL+"/",
@ -194,7 +194,7 @@ func checkAuth(opts *config.Options, c *gin.Context) {
}
if user.EmailVerification == nil {
log.Printf("%s tries to login as %q, but sent an invalid password", c.ClientIP(), lf.Email)
log.Printf("%s tries to login as %q, but has not verified email", c.ClientIP(), lf.Email)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Please validate your e-mail address before your first login.", "href": "/email-validation"})
return
}
@ -223,38 +223,17 @@ func checkAuth(opts *config.Options, c *gin.Context) {
}
func completeAuth(opts *config.Options, c *gin.Context, userprofile UserProfile) (*UserClaims, error) {
// Issue a new JWT token
jti := make([]byte, 16)
_, err := rand.Read(jti)
session := sessions.Default(c)
session.Clear()
session.Set("iduser", userprofile.UserId)
err := session.Save()
if err != nil {
return nil, fmt.Errorf("unable to read enough random bytes: %w", err)
return nil, err
}
iat := jwt.NewNumericDate(time.Now())
claims := &UserClaims{
return &UserClaims{
userprofile,
jwt.RegisteredClaims{
IssuedAt: iat,
ID: base64.StdEncoding.EncodeToString(jti),
},
}
jwtToken := jwt.NewWithClaims(signingMethod, claims)
jwtToken.Header["kid"] = "1"
token, err := jwtToken.SignedString([]byte(opts.JWTSecretKey))
if err != nil {
return nil, fmt.Errorf("unable to sign user claims: %w", err)
}
c.SetCookie(
COOKIE_NAME, // name
token, // value
30*24*3600, // maxAge
opts.BaseURL+"/", // path
"", // domain
opts.DevProxy == "" && opts.ExternalURL.URL.Scheme != "http", // secure
true, // httpOnly
)
return claims, nil
jwt.RegisteredClaims{},
}, nil
}

View File

@ -30,6 +30,7 @@ import (
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/rrivera/identicon"
@ -58,6 +59,10 @@ func declareUsersAuthRoutes(opts *config.Options, router *gin.RouterGroup) {
router.GET("/session", getSession)
router.DELETE("/session", clearSession)
router.GET("/sessions", getSessions)
apiSessionsRoutes := router.Group("/session/:sid")
apiSessionsRoutes.DELETE("", deleteSession)
apiUserRoutes := router.Group("/users/:uid")
apiUserRoutes.Use(userHandler)
@ -449,7 +454,7 @@ func changePassword(opts *config.Options, c *gin.Context) {
log.Printf("%s changes password for user %s", c.ClientIP(), user.Email)
for _, session := range sessions {
err = storage.MainStore.DeleteSession(session)
err = storage.MainStore.DeleteSession(session.Id)
if err != nil {
log.Printf("%s: unable to delete session (password changed): %s", c.ClientIP(), err.Error())
}
@ -507,7 +512,7 @@ func deleteUser(opts *config.Options, c *gin.Context) {
log.Printf("%s: deletes user: %s", c.ClientIP(), user.Email)
for _, session := range sessions {
err = storage.MainStore.DeleteSession(session)
err = storage.MainStore.DeleteSession(session.Id)
if err != nil {
log.Printf("%s: unable to delete session (drop account): %s", c.ClientIP(), err.Error())
}
@ -682,11 +687,17 @@ func recoverUserAccount(c *gin.Context) {
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Session
// @Failure 401 {object} happydns.Error "Authentication failure"
// @Router /sessions [get]
// @Router /session [get]
func getSession(c *gin.Context) {
session := c.MustGet("MySession").(*happydns.Session)
session := sessions.Default(c)
c.JSON(http.StatusOK, session)
s, err := storage.MainStore.GetSession(session.ID())
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
// clearSession removes the content of the current user's session.
@ -700,11 +711,71 @@ func getSession(c *gin.Context) {
// @Security securitydefinitions.basic
// @Success 204 {null} null
// @Failure 401 {object} happydns.Error "Authentication failure"
// @Router /sessions [delete]
// @Router /session [delete]
func clearSession(c *gin.Context) {
session := c.MustGet("MySession").(*happydns.Session)
session := sessions.Default(c)
session.ClearSession()
session.Clear()
c.Status(http.StatusNoContent)
}
// getSessions lists the sessions open for the current user.
//
// @Summary List user's sessions
// @Schemes
// @Description List the sessions open for the current user
// @Tags users
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Session
// @Failure 401 {object} happydns.Error "Authentication failure"
// @Router /sessions [get]
func getSessions(c *gin.Context) {
myuser := c.MustGet("LoggedUser").(*happydns.User)
s, err := storage.MainStore.GetUserSessions(myuser)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
// deleteSession delete a session owned by the current user
//
// @Summary Delete a session owned by the current user.
// @Schemes
// @Description Delete a session owned by the current user.
// @Tags users
// @Accept json
// @Param sessionId path string true "Session identifier"
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.Session
// @Failure 401 {object} happydns.Error "Authentication failure"
// @Router /sessions/{sessionId} [delete]
func deleteSession(c *gin.Context) {
myuser := c.MustGet("LoggedUser").(*happydns.User)
s, err := storage.MainStore.GetSession(c.Param("sid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()})
return
}
if !myuser.Id.Equals(s.IdUser) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "You are not allowed to drop this session."})
return
}
err = storage.MainStore.DeleteSession(c.Param("sid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, nil)
}

View File

@ -24,8 +24,9 @@ package forms // import "git.happydns.org/happyDomain/forms"
import (
"errors"
"github.com/gin-contrib/sessions"
"git.happydns.org/happyDomain/config"
"git.happydns.org/happyDomain/model"
)
// GenRecallID
@ -34,7 +35,7 @@ type GenRecallID func() string
// CustomSettingsForm are functions to declare when we want to display a custom user experience when asking for Source's settings.
type CustomSettingsForm interface {
// DisplaySettingsForm generates the CustomForm corresponding to the asked target state.
DisplaySettingsForm(int32, *config.Options, *happydns.Session, GenRecallID) (*CustomForm, map[string]interface{}, error)
DisplaySettingsForm(int32, *config.Options, *sessions.Session, GenRecallID) (*CustomForm, map[string]interface{}, error)
}
var (

9
go.mod
View File

@ -6,11 +6,16 @@ toolchain go1.22.3
require (
github.com/StackExchange/dnscontrol/v4 v4.3.0
github.com/coreos/go-oidc/v3 v3.10.0
github.com/fatih/color v1.17.0
github.com/gin-contrib/sessions v1.0.1
github.com/gin-gonic/gin v1.10.0
github.com/go-mail/mail v2.3.1+incompatible
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.2.2
github.com/miekg/dns v1.1.59
github.com/mileusna/useragent v1.3.4
github.com/ovh/go-ovh v1.5.1
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1
@ -20,6 +25,7 @@ require (
github.com/yuin/goldmark v1.7.1
go.uber.org/multierr v1.11.0
golang.org/x/crypto v0.23.0
golang.org/x/oauth2 v0.20.0
)
require (
@ -75,6 +81,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-gandi/go-gandi v0.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
@ -94,6 +101,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.4 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
@ -145,7 +153,6 @@ require (
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/oauth2 v0.20.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect

16
go.sum
View File

@ -90,6 +90,8 @@ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -127,6 +129,8 @@ github.com/getkin/kin-openapi v0.87.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSy
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI=
github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
@ -135,6 +139,8 @@ github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=
github.com/go-gandi/go-gandi v0.7.0/go.mod h1:9NoYyfWCjFosClPiWjkbbRK5UViaZ4ctpT8/pKSSFlw=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@ -216,6 +222,8 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -229,7 +237,13 @@ github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoF
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/happyDomain/dnscontrol/v4 v4.11.101 h1:+FvKJRSWYPknhotWzJIuHLOAQ1/e+Si1SlMtvv7RjuI=
@ -305,6 +319,8 @@ github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=

View File

@ -27,10 +27,13 @@ import (
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/api"
"git.happydns.org/happyDomain/config"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/storage"
"git.happydns.org/happyDomain/ui"
)
@ -48,6 +51,7 @@ func NewApp(cfg *config.Options) App {
gin.ForceConsoleColor()
router := gin.New()
router.Use(gin.Logger(), gin.Recovery())
router.Use(sessions.Sessions(session.COOKIE_NAME, session.NewSessionStore(cfg, storage.MainStore, []byte(cfg.JWTSecretKey))))
api.DeclareRoutes(cfg, router)
ui.DeclareRoutes(cfg, router)

View File

@ -0,0 +1,202 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package session // import "git.happydns.org/happyDomain/internal/session"
import (
"encoding/base32"
"fmt"
"net/http"
"strings"
"time"
ginsessions "github.com/gin-contrib/sessions"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/mileusna/useragent"
"git.happydns.org/happyDomain/config"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/storage"
)
const COOKIE_NAME = "happydomain_session"
// SessionStore is an implementation of Gorilla Sessions, using happyDomain storages
type SessionStore struct {
Codecs []securecookie.Codec
options *sessions.Options
Path string
storage storage.Storage
}
func NewSessionStore(opts *config.Options, storage storage.Storage, keyPairs ...[]byte) *SessionStore {
store := &SessionStore{
Codecs: securecookie.CodecsFromPairs(keyPairs...),
options: &sessions.Options{
Path: opts.BaseURL + "/",
MaxAge: 86400 * 30,
Secure: opts.DevProxy == "" && opts.ExternalURL.URL.Scheme != "http",
HttpOnly: true,
},
storage: storage,
}
store.MaxAge(store.options.MaxAge)
return store
}
// Get Fetches a session for a given name after it has been added to the registry.
func (s *SessionStore) Get(r *http.Request, name string) (*sessions.Session, error) {
return sessions.GetRegistry(r).Get(s, name)
}
// New returns a new session for the given name without adding it to the registry.
func (s *SessionStore) New(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(s, name)
options := *s.options
session.Options = &options
session.IsNew = true
var token string
if cookie, err := r.Cookie(name); err == nil {
token = cookie.Value
} else if _, ok := r.Header["Authorization"]; ok && len(r.Header["Authorization"]) > 0 {
if flds := strings.Fields(r.Header["Authorization"][0]); len(flds) == 2 && flds[0] == "Bearer" {
token = flds[1]
}
}
if len(token) == 0 {
// Cookie not found, this is a new session
return session, nil
}
err := securecookie.DecodeMulti(name, token, &session.ID, s.Codecs...)
if err != nil {
// Value could not be decrypted, consider this is a new session
return session, err
}
err = s.load(session)
session.IsNew = false
return session, err
}
// Save saves the given session into the database and deletes cookies if needed.
func (s *SessionStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
var cookieValue string
if s.options.MaxAge < 0 {
s.storage.DeleteSession(session.ID)
} else {
if session.ID == "" {
session.ID = strings.TrimRight(base32.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)), "=")
}
encrypted, err := securecookie.EncodeMulti(session.Name(), session.ID, s.Codecs...)
if err != nil {
return err
}
cookieValue = encrypted
err = s.save(session, r.UserAgent())
}
http.SetCookie(w, sessions.NewCookie(session.Name(), cookieValue, session.Options))
return nil
}
// MaxAge sets the maximum age for the store and the underlying cookie
// implementation. Individual sessions can be deleted by setting Options.MaxAge
// = -1 for that session.
func (s *SessionStore) MaxAge(age int) {
s.options.MaxAge = age
// Set the maxAge for each securecookie instance.
for _, codec := range s.Codecs {
if sc, ok := codec.(*securecookie.SecureCookie); ok {
sc.MaxAge(age)
}
}
}
func (s *SessionStore) Options(options ginsessions.Options) {
s.options = options.ToGorillaOptions()
}
func (s *SessionStore) load(session *sessions.Session) error {
mysession, err := s.storage.GetSession(session.ID)
if err != nil {
return err
}
return securecookie.DecodeMulti(session.Name(), mysession.Content, &session.Values, s.Codecs...)
}
// save writes encoded session.Values to a database record.
func (s *SessionStore) save(session *sessions.Session, ua string) error {
encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, s.Codecs...)
if err != nil {
return err
}
crOn := session.Values["created_on"]
exOn := session.Values["expires_on"]
var expiresOn time.Time
createdOn, ok := crOn.(time.Time)
if !ok {
createdOn = time.Now()
}
if exOn == nil {
expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
} else {
expiresOn = exOn.(time.Time)
if expiresOn.Sub(time.Now().Add(time.Second*time.Duration(session.Options.MaxAge))) < 0 {
expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
}
}
var iduser happydns.Identifier
if iu, ok := session.Values["iduser"].([]byte); ok {
iduser = iu
}
var description string
if descr, ok := session.Values["description"].(string); ok {
description = descr
} else {
browser := useragent.Parse(ua)
description = fmt.Sprintf("%s on %s", browser.Name, browser.OS)
session.Values["description"] = description
}
mysession := &happydns.Session{
Id: session.ID,
IdUser: iduser,
Content: encoded,
Description: description,
IssuedAt: createdOn,
ExpiresOn: expiresOn,
ModifiedOn: time.Now(),
}
return s.storage.UpdateSession(mysession)
}

View File

@ -22,107 +22,34 @@
package happydns
import (
"crypto/rand"
"encoding/json"
"fmt"
mrand "math/rand"
"time"
)
// Session holds informatin about a User's currently connected.
type Session struct {
// Id is the Session's identifier.
Id Identifier `json:"id" swaggertype:"string"`
Id string `json:"id"`
// IdUser is the User's identifier of the Session.
IdUser Identifier `json:"login" swaggertype:"string"`
// Description is a user defined string aims to identify each session.
Description string `json:"description"`
// IssuedAt holds the creation date of the Session.
IssuedAt time.Time `json:"time"`
// ExpiresOn holds the expirate date of the Session.
ExpiresOn time.Time `json:"exp"`
// ModifiedOn is the last time the session has been updated.
ModifiedOn time.Time `json:"upd"`
// Content stores data filled by other modules.
Content map[string][]byte `json:"content,omitempty"`
// changed indicates if Content has changed since its loading.
changed bool
}
// NewSession fills a new Session structure.
func NewSession(user *User) (s *Session, err error) {
session_id := make([]byte, 16)
_, err = rand.Read(session_id)
if err == nil {
s = &Session{
Id: session_id,
IdUser: user.Id,
IssuedAt: time.Now(),
}
}
return
}
// HasChanged tells if the Session has changed since its last loading.
func (s *Session) HasChanged() bool {
return s.changed
}
// FindNewKey returns a key and an identifier appended to the given
// prefix, that is available in the User's Session.
func (s *Session) FindNewKey(prefix string) (key string, id int64) {
for {
// max random id is 2^53 to fit on float64 without loosing precision (JSON limitation)
id = mrand.Int63n(1 << 53)
key = fmt.Sprintf("%s%d", prefix, id)
if _, ok := s.Content[key]; !ok {
return
}
}
}
// SetValue defines, erase or delete a content to stores at the given
// key. If the key is already defined, it erases its content. If the
// given value is nil, it deletes the key.
func (s *Session) SetValue(key string, value interface{}) {
if s.Content == nil && value != nil {
s.Content = map[string][]byte{}
}
if value == nil {
if s.Content == nil {
return
} else if _, ok := s.Content[key]; !ok {
return
} else {
delete(s.Content, key)
s.changed = true
}
} else {
s.Content[key], _ = json.Marshal(value)
s.changed = true
}
}
// GetValue retrieves data stored at the given key. Returns true if
// the key exists and if the value has been filled correctly.
func (s *Session) GetValue(key string, value interface{}) bool {
if v, ok := s.Content[key]; !ok {
return false
} else if json.Unmarshal(v, value) != nil {
return false
} else {
return true
}
}
// DropKey removes the given key from the Session's Content.
func (s *Session) DropKey(key string) {
s.SetValue(key, nil)
Content string `json:"content,omitempty"`
}
// ClearSession removes all content from the Session.
func (s *Session) ClearSession() {
s.Content = nil
s.changed = true
s.Content = ""
}

View File

@ -25,11 +25,11 @@ import (
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/ovh/go-ovh/ovh"
"git.happydns.org/happyDomain/config"
"git.happydns.org/happyDomain/forms"
"git.happydns.org/happyDomain/model"
)
func settingsForm(edit bool) *forms.CustomForm {
@ -61,7 +61,7 @@ func settingsForm(edit bool) *forms.CustomForm {
return form
}
func settingsAskCredentials(cfg *config.Options, recallid string, session *happydns.Session) (*forms.CustomForm, map[string]interface{}, error) {
func settingsAskCredentials(cfg *config.Options, recallid string, session *sessions.Session) (*forms.CustomForm, map[string]interface{}, error) {
client, err := ovh.NewClient("ovh-eu", appKey, appSecret, "")
if err != nil {
return nil, nil, fmt.Errorf("Unable to generate Consumer key, as OVH client can't be created: %w", err)
@ -89,7 +89,7 @@ func settingsAskCredentials(cfg *config.Options, recallid string, session *happy
}, nil
}
func (s *OVHAPI) DisplaySettingsForm(state int32, cfg *config.Options, session *happydns.Session, genRecallId forms.GenRecallID) (*forms.CustomForm, map[string]interface{}, error) {
func (s *OVHAPI) DisplaySettingsForm(state int32, cfg *config.Options, session *sessions.Session, genRecallId forms.GenRecallID) (*forms.CustomForm, map[string]interface{}, error) {
switch state {
case 0:
return settingsForm(s.ConsumerKey != ""), nil, nil

View File

@ -129,7 +129,7 @@ type Storage interface {
// SESSIONS ---------------------------------------------------
// GetSession retrieves the Session with the given identifier.
GetSession(id happydns.Identifier) (*happydns.Session, error)
GetSession(id string) (*happydns.Session, error)
// GetAuthUserSessions retrieves all Session for the given AuthUser.
GetAuthUserSessions(user *happydns.UserAuth) ([]*happydns.Session, error)
@ -137,14 +137,11 @@ type Storage interface {
// GetUserSessions retrieves all Session for the given User.
GetUserSessions(user *happydns.User) ([]*happydns.Session, error)
// CreateSession creates a record in the database for the given Session.
CreateSession(session *happydns.Session) error
// UpdateSession updates the fields of the given Session.
UpdateSession(session *happydns.Session) error
// DeleteSession removes the given Session from the database.
DeleteSession(session *happydns.Session) error
DeleteSession(id string) error
// ClearSessions deletes all Sessions present in the database.
ClearSessions() error

View File

@ -37,8 +37,8 @@ func (s *LevelDBStorage) getSession(id string) (session *happydns.Session, err e
return
}
func (s *LevelDBStorage) GetSession(id happydns.Identifier) (session *happydns.Session, err error) {
return s.getSession(fmt.Sprintf("user.session-%s", id.String()))
func (s *LevelDBStorage) GetSession(id string) (session *happydns.Session, err error) {
return s.getSession(fmt.Sprintf("user.session-%s", id))
}
func (s *LevelDBStorage) GetAuthUserSessions(user *happydns.UserAuth) (sessions []*happydns.Session, err error) {
@ -52,7 +52,9 @@ func (s *LevelDBStorage) GetAuthUserSessions(user *happydns.UserAuth) (sessions
if err != nil {
return
}
sessions = append(sessions, &s)
if s.IdUser.Equals(user.Id) {
sessions = append(sessions, &s)
}
}
return
@ -69,29 +71,20 @@ func (s *LevelDBStorage) GetUserSessions(user *happydns.User) (sessions []*happy
if err != nil {
return
}
sessions = append(sessions, &s)
if s.IdUser.Equals(user.Id) {
sessions = append(sessions, &s)
}
}
return
}
func (s *LevelDBStorage) CreateSession(session *happydns.Session) error {
key, id, err := s.findIdentifierKey("user.session-")
if err != nil {
return err
}
session.Id = id
return s.put(key, session)
}
func (s *LevelDBStorage) UpdateSession(session *happydns.Session) error {
return s.put(fmt.Sprintf("user.session-%s", session.Id.String()), session)
return s.put(fmt.Sprintf("user.session-%s", session.Id), session)
}
func (s *LevelDBStorage) DeleteSession(session *happydns.Session) error {
return s.delete(fmt.Sprintf("user.session-%s", session.Id.String()))
func (s *LevelDBStorage) DeleteSession(id string) error {
return s.delete(fmt.Sprintf("user.session-%s", id))
}
func (s *LevelDBStorage) ClearSessions() error {

View File

@ -0,0 +1,31 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"log"
)
func migrateFrom6(s *LevelDBStorage) error {
log.Println("Drop all sessions to use new format")
return s.ClearSessions()
}

View File

@ -35,6 +35,7 @@ var migrations []LevelDBMigrationFunc = []LevelDBMigrationFunc{
migrateFrom3,
migrateFrom4,
migrateFrom5,
migrateFrom6,
}
func (s *LevelDBStorage) DoMigration() (err error) {