Skip to content

Commit fa33d3f

Browse files
authored
Session improvements (#510)
1 parent 46b0934 commit fa33d3f

File tree

9 files changed

+274
-36
lines changed

9 files changed

+274
-36
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ docker-compose up
4242
| `BIND_ADDRESS` | The addresses that can access to the web interface and the port, use unix:///abspath/to/file.socket for unix domain socket. | 0.0.0.0:80 |
4343
| `SESSION_SECRET` | The secret key used to encrypt the session cookies. Set this to a random value | N/A |
4444
| `SESSION_SECRET_FILE` | Optional filepath for the secret key used to encrypt the session cookies. Leave `SESSION_SECRET` blank to take effect | N/A |
45+
| `SESSION_MAX_DURATION` | Max time in days a remembered session is refreshed and valid. Non-refreshed session is valid for 7 days max, regardless of this setting. | 90 |
4546
| `SUBNET_RANGES` | The list of address subdivision ranges. Format: `SR Name:10.0.1.0/24; SR2:10.0.2.0/24,10.0.3.0/24` Each CIDR must be inside one of the server interfaces. | N/A |
4647
| `WGUI_USERNAME` | The username for the login page. Used for db initialization only | `admin` |
4748
| `WGUI_PASSWORD` | The password for the user on the login page. Will be hashed automatically. Used for db initialization only | `admin` |

handler/routes.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,32 +93,41 @@ func Login(db store.IStore) echo.HandlerFunc {
9393
}
9494

9595
if userCorrect && passwordCorrect {
96-
// TODO: refresh the token
9796
ageMax := 0
98-
expiration := time.Now().Add(24 * time.Hour)
9997
if rememberMe {
100-
ageMax = 86400
101-
expiration.Add(144 * time.Hour)
98+
ageMax = 86400 * 7
10299
}
100+
101+
cookiePath := util.GetCookiePath()
102+
103103
sess, _ := session.Get("session", c)
104104
sess.Options = &sessions.Options{
105-
Path: util.BasePath,
105+
Path: cookiePath,
106106
MaxAge: ageMax,
107107
HttpOnly: true,
108+
SameSite: http.SameSiteLaxMode,
108109
}
109110

110111
// set session_token
111112
tokenUID := xid.New().String()
113+
now := time.Now().UTC().Unix()
112114
sess.Values["username"] = dbuser.Username
115+
sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser)
113116
sess.Values["admin"] = dbuser.Admin
114117
sess.Values["session_token"] = tokenUID
118+
sess.Values["max_age"] = ageMax
119+
sess.Values["created_at"] = now
120+
sess.Values["updated_at"] = now
115121
sess.Save(c.Request(), c.Response())
116122

117123
// set session_token in cookie
118124
cookie := new(http.Cookie)
119125
cookie.Name = "session_token"
126+
cookie.Path = cookiePath
120127
cookie.Value = tokenUID
121-
cookie.Expires = expiration
128+
cookie.MaxAge = ageMax
129+
cookie.HttpOnly = true
130+
cookie.SameSite = http.SameSiteLaxMode
122131
c.SetCookie(cookie)
123132

124133
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
@@ -256,7 +265,7 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
256265
log.Infof("Updated user information successfully")
257266

258267
if previousUsername == currentUser(c) {
259-
setUser(c, user.Username, user.Admin)
268+
setUser(c, user.Username, user.Admin, util.GetDBUserCRC32(user))
260269
}
261270

262271
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated user information successfully"})

handler/session.go

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package handler
33
import (
44
"fmt"
55
"net/http"
6+
"time"
67

8+
"github.com/gorilla/sessions"
79
"github.com/labstack/echo-contrib/session"
810
"github.com/labstack/echo/v4"
911
"github.com/ngoduykhanh/wireguard-ui/util"
@@ -23,6 +25,15 @@ func ValidSession(next echo.HandlerFunc) echo.HandlerFunc {
2325
}
2426
}
2527

28+
// RefreshSession must only be used after ValidSession middleware
29+
// RefreshSession checks if the session is eligible for the refresh, but doesn't check if it's fully valid
30+
func RefreshSession(next echo.HandlerFunc) echo.HandlerFunc {
31+
return func(c echo.Context) error {
32+
doRefreshSession(c)
33+
return next(c)
34+
}
35+
}
36+
2637
func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc {
2738
return func(c echo.Context) error {
2839
if !isAdmin(c) {
@@ -41,9 +52,146 @@ func isValidSession(c echo.Context) bool {
4152
if err != nil || sess.Values["session_token"] != cookie.Value {
4253
return false
4354
}
55+
56+
// Check time bounds
57+
createdAt := getCreatedAt(sess)
58+
updatedAt := getUpdatedAt(sess)
59+
maxAge := getMaxAge(sess)
60+
// Temporary session is considered valid within 24h if browser is not closed before
61+
// This value is not saved and is used as virtual expiration
62+
if maxAge == 0 {
63+
maxAge = 86400
64+
}
65+
expiration := updatedAt + int64(maxAge)
66+
now := time.Now().UTC().Unix()
67+
if updatedAt > now || expiration < now || createdAt+util.SessionMaxDuration < now {
68+
return false
69+
}
70+
71+
// Check if user still exists and unchanged
72+
username := fmt.Sprintf("%s", sess.Values["username"])
73+
userHash := getUserHash(sess)
74+
if uHash, ok := util.DBUsersToCRC32[username]; !ok || userHash != uHash {
75+
return false
76+
}
77+
4478
return true
4579
}
4680

81+
// Refreshes a "remember me" session when the user visits web pages (not API)
82+
// Session must be valid before calling this function
83+
// Refresh is performed at most once per 24h
84+
func doRefreshSession(c echo.Context) {
85+
if util.DisableLogin {
86+
return
87+
}
88+
89+
sess, _ := session.Get("session", c)
90+
maxAge := getMaxAge(sess)
91+
if maxAge <= 0 {
92+
return
93+
}
94+
95+
oldCookie, err := c.Cookie("session_token")
96+
if err != nil || sess.Values["session_token"] != oldCookie.Value {
97+
return
98+
}
99+
100+
// Refresh no sooner than 24h
101+
createdAt := getCreatedAt(sess)
102+
updatedAt := getUpdatedAt(sess)
103+
expiration := updatedAt + int64(getMaxAge(sess))
104+
now := time.Now().UTC().Unix()
105+
if updatedAt > now || expiration < now || now-updatedAt < 86_400 || createdAt+util.SessionMaxDuration < now {
106+
return
107+
}
108+
109+
cookiePath := util.GetCookiePath()
110+
111+
sess.Values["updated_at"] = now
112+
sess.Options = &sessions.Options{
113+
Path: cookiePath,
114+
MaxAge: maxAge,
115+
HttpOnly: true,
116+
SameSite: http.SameSiteLaxMode,
117+
}
118+
sess.Save(c.Request(), c.Response())
119+
120+
cookie := new(http.Cookie)
121+
cookie.Name = "session_token"
122+
cookie.Path = cookiePath
123+
cookie.Value = oldCookie.Value
124+
cookie.MaxAge = maxAge
125+
cookie.HttpOnly = true
126+
cookie.SameSite = http.SameSiteLaxMode
127+
c.SetCookie(cookie)
128+
}
129+
130+
// Get time in seconds this session is valid without updating
131+
func getMaxAge(sess *sessions.Session) int {
132+
if util.DisableLogin {
133+
return 0
134+
}
135+
136+
maxAge := sess.Values["max_age"]
137+
138+
switch typedMaxAge := maxAge.(type) {
139+
case int:
140+
return typedMaxAge
141+
default:
142+
return 0
143+
}
144+
}
145+
146+
// Get a timestamp in seconds of the time the session was created
147+
func getCreatedAt(sess *sessions.Session) int64 {
148+
if util.DisableLogin {
149+
return 0
150+
}
151+
152+
createdAt := sess.Values["created_at"]
153+
154+
switch typedCreatedAt := createdAt.(type) {
155+
case int64:
156+
return typedCreatedAt
157+
default:
158+
return 0
159+
}
160+
}
161+
162+
// Get a timestamp in seconds of the last session update
163+
func getUpdatedAt(sess *sessions.Session) int64 {
164+
if util.DisableLogin {
165+
return 0
166+
}
167+
168+
lastUpdate := sess.Values["updated_at"]
169+
170+
switch typedLastUpdate := lastUpdate.(type) {
171+
case int64:
172+
return typedLastUpdate
173+
default:
174+
return 0
175+
}
176+
}
177+
178+
// Get CRC32 of a user at the moment of log in
179+
// Any changes to user will result in logout of other (not updated) sessions
180+
func getUserHash(sess *sessions.Session) uint32 {
181+
if util.DisableLogin {
182+
return 0
183+
}
184+
185+
userHash := sess.Values["user_hash"]
186+
187+
switch typedUserHash := userHash.(type) {
188+
case uint32:
189+
return typedUserHash
190+
default:
191+
return 0
192+
}
193+
}
194+
47195
// currentUser to get username of logged in user
48196
func currentUser(c echo.Context) string {
49197
if util.DisableLogin {
@@ -66,9 +214,10 @@ func isAdmin(c echo.Context) bool {
66214
return admin == "true"
67215
}
68216

69-
func setUser(c echo.Context, username string, admin bool) {
217+
func setUser(c echo.Context, username string, admin bool, userCRC32 uint32) {
70218
sess, _ := session.Get("session", c)
71219
sess.Values["username"] = username
220+
sess.Values["user_hash"] = userCRC32
72221
sess.Values["admin"] = admin
73222
sess.Save(c.Request(), c.Response())
74223
}
@@ -77,7 +226,24 @@ func setUser(c echo.Context, username string, admin bool) {
77226
func clearSession(c echo.Context) {
78227
sess, _ := session.Get("session", c)
79228
sess.Values["username"] = ""
229+
sess.Values["user_hash"] = 0
80230
sess.Values["admin"] = false
81231
sess.Values["session_token"] = ""
232+
sess.Values["max_age"] = -1
233+
sess.Options.MaxAge = -1
82234
sess.Save(c.Request(), c.Response())
235+
236+
cookiePath := util.GetCookiePath()
237+
238+
cookie, err := c.Cookie("session_token")
239+
if err != nil {
240+
cookie = new(http.Cookie)
241+
}
242+
243+
cookie.Name = "session_token"
244+
cookie.Path = cookiePath
245+
cookie.MaxAge = -1
246+
cookie.HttpOnly = true
247+
cookie.SameSite = http.SameSiteLaxMode
248+
c.SetCookie(cookie)
83249
}

main.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"crypto/sha512"
45
"embed"
56
"flag"
67
"fmt"
@@ -48,6 +49,7 @@ var (
4849
flagTelegramAllowConfRequest = false
4950
flagTelegramFloodWait = 60
5051
flagSessionSecret = util.RandomString(32)
52+
flagSessionMaxDuration = 90
5153
flagWgConfTemplate string
5254
flagBasePath string
5355
flagSubnetRanges string
@@ -91,6 +93,7 @@ func init() {
9193
flag.StringVar(&flagWgConfTemplate, "wg-conf-template", util.LookupEnvOrString("WG_CONF_TEMPLATE", flagWgConfTemplate), "Path to custom wg.conf template.")
9294
flag.StringVar(&flagBasePath, "base-path", util.LookupEnvOrString("BASE_PATH", flagBasePath), "The base path of the URL")
9395
flag.StringVar(&flagSubnetRanges, "subnet-ranges", util.LookupEnvOrString("SUBNET_RANGES", flagSubnetRanges), "IP ranges to choose from when assigning an IP for a client.")
96+
flag.IntVar(&flagSessionMaxDuration, "session-max-duration", util.LookupEnvOrInt("SESSION_MAX_DURATION", flagSessionMaxDuration), "Max time in days a remembered session is refreshed and valid.")
9497

9598
var (
9699
smtpPasswordLookup = util.LookupEnvOrString("SMTP_PASSWORD", flagSmtpPassword)
@@ -135,7 +138,8 @@ func init() {
135138
util.SendgridApiKey = flagSendgridApiKey
136139
util.EmailFrom = flagEmailFrom
137140
util.EmailFromName = flagEmailFromName
138-
util.SessionSecret = []byte(flagSessionSecret)
141+
util.SessionSecret = sha512.Sum512([]byte(flagSessionSecret))
142+
util.SessionMaxDuration = int64(flagSessionMaxDuration) * 86_400 // Store in seconds
139143
util.WgConfTemplate = flagWgConfTemplate
140144
util.BasePath = util.ParseBasePath(flagBasePath)
141145
util.SubnetRanges = util.ParseSubnetRanges(flagSubnetRanges)
@@ -204,7 +208,7 @@ func main() {
204208
// register routes
205209
app := router.New(tmplDir, extraData, util.SessionSecret)
206210

207-
app.GET(util.BasePath, handler.WireGuardClients(db), handler.ValidSession)
211+
app.GET(util.BasePath, handler.WireGuardClients(db), handler.ValidSession, handler.RefreshSession)
208212

209213
// Important: Make sure that all non-GET routes check the request content type using handler.ContentTypeJson to
210214
// mitigate CSRF attacks. This is effective, because browsers don't allow setting the Content-Type header on
@@ -214,8 +218,8 @@ func main() {
214218
app.GET(util.BasePath+"/login", handler.LoginPage())
215219
app.POST(util.BasePath+"/login", handler.Login(db), handler.ContentTypeJson)
216220
app.GET(util.BasePath+"/logout", handler.Logout(), handler.ValidSession)
217-
app.GET(util.BasePath+"/profile", handler.LoadProfile(), handler.ValidSession)
218-
app.GET(util.BasePath+"/users-settings", handler.UsersSettings(), handler.ValidSession, handler.NeedsAdmin)
221+
app.GET(util.BasePath+"/profile", handler.LoadProfile(), handler.ValidSession, handler.RefreshSession)
222+
app.GET(util.BasePath+"/users-settings", handler.UsersSettings(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
219223
app.POST(util.BasePath+"/update-user", handler.UpdateUser(db), handler.ValidSession, handler.ContentTypeJson)
220224
app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
221225
app.POST(util.BasePath+"/remove-user", handler.RemoveUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
@@ -241,19 +245,19 @@ func main() {
241245
app.POST(util.BasePath+"/client/set-status", handler.SetClientStatus(db), handler.ValidSession, handler.ContentTypeJson)
242246
app.POST(util.BasePath+"/remove-client", handler.RemoveClient(db), handler.ValidSession, handler.ContentTypeJson)
243247
app.GET(util.BasePath+"/download", handler.DownloadClient(db), handler.ValidSession)
244-
app.GET(util.BasePath+"/wg-server", handler.WireGuardServer(db), handler.ValidSession, handler.NeedsAdmin)
248+
app.GET(util.BasePath+"/wg-server", handler.WireGuardServer(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
245249
app.POST(util.BasePath+"/wg-server/interfaces", handler.WireGuardServerInterfaces(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
246250
app.POST(util.BasePath+"/wg-server/keypair", handler.WireGuardServerKeyPair(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
247-
app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.NeedsAdmin)
251+
app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
248252
app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
249-
app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession)
253+
app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession)
250254
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
251255
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
252256
app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession)
253257
app.GET(util.BasePath+"/api/subnet-ranges", handler.GetOrderedSubnetRanges(), handler.ValidSession)
254258
app.GET(util.BasePath+"/api/suggest-client-ips", handler.SuggestIPAllocation(db), handler.ValidSession)
255259
app.POST(util.BasePath+"/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson)
256-
app.GET(util.BasePath+"/wake_on_lan_hosts", handler.GetWakeOnLanHosts(db), handler.ValidSession)
260+
app.GET(util.BasePath+"/wake_on_lan_hosts", handler.GetWakeOnLanHosts(db), handler.ValidSession, handler.RefreshSession)
257261
app.POST(util.BasePath+"/wake_on_lan_host", handler.SaveWakeOnLanHost(db), handler.ValidSession, handler.ContentTypeJson)
258262
app.DELETE(util.BasePath+"/wake_on_lan_host/:mac_address", handler.DeleteWakeOnHost(db), handler.ValidSession, handler.ContentTypeJson)
259263
app.PUT(util.BasePath+"/wake_on_lan_host/:mac_address", handler.WakeOnHost(db), handler.ValidSession, handler.ContentTypeJson)

router/router.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,17 @@ func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c
4848
}
4949

5050
// New function
51-
func New(tmplDir fs.FS, extraData map[string]interface{}, secret []byte) *echo.Echo {
51+
func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo {
5252
e := echo.New()
53-
e.Use(session.Middleware(sessions.NewCookieStore(secret)))
53+
54+
cookiePath := util.GetCookiePath()
55+
56+
cookieStore := sessions.NewCookieStore(secret[:32], secret[32:])
57+
cookieStore.Options.Path = cookiePath
58+
cookieStore.Options.HttpOnly = true
59+
cookieStore.MaxAge(86400 * 7)
60+
61+
e.Use(session.Middleware(cookieStore))
5462

5563
// read html template file to string
5664
tmplBaseString, err := util.StringFromEmbedFile(tmplDir, "base.html")

0 commit comments

Comments
 (0)