diff --git a/docs/configuration.md b/docs/configuration.md index 73083d9..0e98fe3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,6 +7,7 @@ - [Other ways of providing tokens/passwords/secrets](#other-ways-of-providing-tokenspasswordssecrets) - [Including other config files](#including-other-config-files) - [Config schema](#config-schema) +- [Authentication](#authentication) - [Server](#server) - [Document](#document) - [Branding](#branding) @@ -187,6 +188,67 @@ This assumes that the config you want to print is in your current working direct For property descriptions, validation and autocompletion of the config within your IDE, @not-first has kindly created a [schema](https://github.com/not-first/glance-schema). Massive thanks to them for this, go check it out and give them a star! +## Authentication + +To make sure that only you and the people you want to share your dashboard with have access to it, you can set up authentication via username and password. This is done through a top level `auth` property. Example: + +```yaml +auth: + secret-key: # this must be set to a random value generated using the secret:make CLI command + users: + admin: + password: 123456 + svilen: + password: 123456 +``` + +To generate a secret key, run the following command: + +```sh +./glance secret:make +``` + +Or with Docker: + +```sh +docker run --rm glanceapp/glance secret:make +``` + +### Using hashed passwords + +If you do not want to store plain passwords in your config file or in environment variables, you can hash your password and provide its hash instead: + +```sh +./glance password:hash mysecretpassword +``` + +Or with Docker: + +```sh +docker run --rm glanceapp/glance password:hash mysecretpassword +``` + +Then, in your config file use the `password-hash` property instead of `password`: + +```yaml +auth: + secret-key: # this must be set to a random value generated using the secret:make CLI command + users: + admin: + password-hash: $2a$10$o6SXqiccI3DDP2dN4ADumuOeIHET6Q4bUMYZD6rT2Aqt6XQ3DyO.6 +``` + +### Preventing brute-force attacks + +Glance will automatically block IP addresses of users who fail to authenticate 5 times in a row in the span of 5 minutes. In order for this feature to work correctly, Glance must know the real IP address of requests. If you're using a reverse proxy such as nginx, Traefik, NPM, etc, you must set the `proxied` property in the `server` configuration to `true`: + +```yaml +server: + proxied: true +``` + +When set to `true`, Glance will use the `X-Forwarded-For` header to determine the original IP address of the request, so make sure that your reverse proxy is correctly configured to send that header. + ## Server Server configuration is done through a top level `server` property. Example: @@ -202,6 +264,7 @@ server: | ---- | ---- | -------- | ------- | | host | string | no | | | port | number | no | 8080 | +| proxied | boolean | no | false | | base-url | string | no | | | assets-path | string | no | | @@ -211,6 +274,9 @@ The address which the server will listen on. Setting it to `localhost` means tha #### `port` A number between 1 and 65,535, so long as that port isn't already used by anything else. +#### `proxied` +Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details. + #### `base-url` The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path. diff --git a/go.mod b/go.mod index 4c19477..ccea58c 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,15 @@ go 1.24.2 require ( github.com/fsnotify/fsnotify v1.9.0 github.com/mmcdole/gofeed v1.3.0 - github.com/shirou/gopsutil/v4 v4.25.3 + github.com/shirou/gopsutil/v4 v4.25.4 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.24.0 + golang.org/x/crypto v0.38.0 + golang.org/x/text v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/PuerkitoBio/goquery v1.10.2 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -27,6 +28,6 @@ require ( github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index 9a79559..80c2d6c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8W github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -16,7 +18,6 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -43,6 +44,8 @@ github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0Zqm github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= +github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= +github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -71,6 +74,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -89,6 +96,8 @@ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -114,6 +123,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -136,6 +147,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/glance/auth.go b/internal/glance/auth.go new file mode 100644 index 0000000..e6497a1 --- /dev/null +++ b/internal/glance/auth.go @@ -0,0 +1,343 @@ +package glance + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + mathrand "math/rand/v2" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const AUTH_SESSION_COOKIE_NAME = "session_token" +const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute +const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5 + +const AUTH_TOKEN_SECRET_LENGTH = 32 +const AUTH_USERNAME_HASH_LENGTH = 32 +const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH +const AUTH_TIMESTAMP_LENGTH = 4 // uint32 +const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH + +// How long the token will be valid for +const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days +// How long the token has left before it should be regenerated +const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days + +var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html") + +type doWhenUnauthorized int + +const ( + redirectToLogin doWhenUnauthorized = iota + showUnauthorizedJSON +) + +type failedAuthAttempt struct { + attempts int + first time.Time +} + +func generateSessionToken(username string, secret []byte, now time.Time) (string, error) { + if len(secret) != AUTH_SECRET_KEY_LENGTH { + return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + usernameHash, err := computeUsernameHash(username, secret) + if err != nil { + return "", err + } + + data := make([]byte, AUTH_TOKEN_DATA_LENGTH) + copy(data, usernameHash) + expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix() + binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires)) + + h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH]) + h.Write(data) + + signature := h.Sum(nil) + encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...)) + // encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64 + + return encodedToken, nil +} + +func computeUsernameHash(username string, secret []byte) ([]byte, error) { + if len(secret) != AUTH_SECRET_KEY_LENGTH { + return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:]) + h.Write([]byte(username)) + + return h.Sum(nil), nil +} + +func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) { + tokenBytes, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return nil, false, err + } + + if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 { + return nil, false, fmt.Errorf("token length is invalid") + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH] + timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH] + providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:] + + h := hmac.New(sha256.New, secretBytes[0:32]) + h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH]) + expectedSignatureBytes := h.Sum(nil) + + if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) { + return nil, false, fmt.Errorf("signature does not match") + } + + expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes)) + if now.Unix() > expiresTimestamp { + return nil, false, fmt.Errorf("token has expired") + } + + return usernameHashBytes, + // True if the token should be regenerated + time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now), + nil +} + +func makeAuthSecretKey(length int) (string, error) { + key := make([]byte, length) + _, err := rand.Read(key) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key), nil +} + +func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + return + } + + waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond + + ip := a.addressOfRequest(r) + + a.authAttemptsMu.Lock() + exceededRateLimit, retryAfter := func() (bool, int) { + attempt, exists := a.failedAuthAttempts[ip] + if !exists { + a.failedAuthAttempts[ip] = &failedAuthAttempt{ + attempts: 1, + first: time.Now(), + } + + return false, 0 + } + + elapsed := time.Since(attempt.first) + if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS { + return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds())) + } + + attempt.attempts++ + return false, 0 + }() + + if exceededRateLimit { + a.authAttemptsMu.Unlock() + time.Sleep(waitOnFailure) + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.WriteHeader(http.StatusTooManyRequests) + return + } else { + // Clean up old failed attempts + for ipOfAttempt := range a.failedAuthAttempts { + if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW { + delete(a.failedAuthAttempts, ipOfAttempt) + } + } + a.authAttemptsMu.Unlock() + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + + err = json.Unmarshal(body, &creds) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + logAuthFailure := func() { + log.Printf( + "Failed login attempt for user '%s' from %s", + creds.Username, ip, + ) + } + + if len(creds.Username) == 0 || len(creds.Password) == 0 { + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if len(creds.Username) > 50 || len(creds.Password) > 100 { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + u, exists := a.Config.Auth.Users[creds.Username] + if !exists { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now()) + if err != nil { + log.Printf("Could not compute session token during login attempt: %v", err) + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD)) + + a.authAttemptsMu.Lock() + delete(a.failedAuthAttempts, ip) + a.authAttemptsMu.Unlock() + + w.WriteHeader(http.StatusOK) +} + +func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool { + if !a.RequiresAuth { + return true + } + + token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME) + if err != nil || token.Value == "" { + return false + } + + usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now()) + if err != nil { + return false + } + + username, exists := a.usernameHashToUsername[string(usernameHash)] + if !exists { + return false + } + + _, exists = a.Config.Auth.Users[username] + if !exists { + return false + } + + if shouldRegenerate { + newToken, err := generateSessionToken(username, a.authSecretKey, time.Now()) + if err != nil { + log.Printf("Could not compute session token during regeneration: %v", err) + return false + } + + a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD)) + } + + return true +} + +// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized +func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool { + if a.isAuthorized(w, r) { + return false + } + + switch fallback { + case redirectToLogin: + http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther) + case showUnauthorizedJSON: + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "Unauthorized"}`)) + } + + return true +} + +// Maybe this should be a POST request instead? +func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) { + a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour)) + http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther) +} + +func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: AUTH_SESSION_COOKIE_NAME, + Value: token, + Expires: expires, + Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https", + Path: a.Config.Server.BaseURL + "/", + SameSite: http.SameSiteLaxMode, + HttpOnly: true, + }) +} + +func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) { + if a.isAuthorized(w, r) { + http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther) + return + } + + data := &templateData{ + App: a, + } + a.populateTemplateRequestData(&data.Request, r) + + var responseBytes bytes.Buffer + err := loginPageTemplate.Execute(&responseBytes, data) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Write(responseBytes.Bytes()) +} diff --git a/internal/glance/auth_test.go b/internal/glance/auth_test.go new file mode 100644 index 0000000..97e6bc9 --- /dev/null +++ b/internal/glance/auth_test.go @@ -0,0 +1,85 @@ +package glance + +import ( + "bytes" + "encoding/base64" + "testing" + "time" +) + +func TestAuthTokenGenerationAndVerification(t *testing.T) { + secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) + if err != nil { + t.Fatalf("Failed to generate secret key: %v", err) + } + + secretBytes, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + t.Fatalf("Failed to decode secret key: %v", err) + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + now := time.Now() + username := "admin" + + token, err := generateSessionToken(username, secretBytes, now) + if err != nil { + t.Fatalf("Failed to generate session token: %v", err) + } + + usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now) + if err != nil { + t.Fatalf("Failed to verify session token: %v", err) + } + + if shouldRegen { + t.Fatal("Token should not need to be regenerated immediately after generation") + } + + computedUsernameHash, err := computeUsernameHash(username, secretBytes) + if err != nil { + t.Fatalf("Failed to compute username hash: %v", err) + } + + if !bytes.Equal(usernameHashBytes, computedUsernameHash) { + t.Fatal("Username hash does not match the expected value") + } + + // Test token regeneration + timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second) + _, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod) + if err != nil { + t.Fatalf("Token verification should not fail during regeneration period, err: %v", err) + } + + if !shouldRegen { + t.Fatal("Token should have been marked for regeneration") + } + + // Test token expiration + _, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second)) + if err == nil { + t.Fatal("Expected token verification to fail after token expiration") + } + + // Test tampered token + decodedToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + t.Fatalf("Failed to decode token: %v", err) + } + + // If any of the bytes are off by 1, the token should be considered invalid + for i := range len(decodedToken) { + tampered := make([]byte, len(decodedToken)) + copy(tampered, decodedToken) + tampered[i] += 1 + + _, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now) + if err == nil { + t.Fatalf("Expected token verification to fail for tampered token at index %d", i) + } + } +} diff --git a/internal/glance/cli.go b/internal/glance/cli.go index 0a76e66..5544b8b 100644 --- a/internal/glance/cli.go +++ b/internal/glance/cli.go @@ -20,6 +20,8 @@ const ( cliIntentDiagnose cliIntentSensorsPrint cliIntentMountpointInfo + cliIntentSecretMake + cliIntentPasswordHash ) type cliOptions struct { @@ -46,12 +48,15 @@ func parseCliOptions() (*cliOptions, error) { flags.PrintDefaults() fmt.Println("\nCommands:") - fmt.Println(" config:validate Validate the config file") - fmt.Println(" config:print Print the parsed config file with embedded includes") - fmt.Println(" sensors:print List all sensors") - fmt.Println(" mountpoint:info Print information about a given mountpoint path") - fmt.Println(" diagnose Run diagnostic checks") + fmt.Println(" config:validate Validate the config file") + fmt.Println(" config:print Print the parsed config file with embedded includes") + fmt.Println(" password:hash Hash a password") + fmt.Println(" secret:make Generate a random secret key") + fmt.Println(" sensors:print List all sensors") + fmt.Println(" mountpoint:info Print information about a given mountpoint path") + fmt.Println(" diagnose Run diagnostic checks") } + configPath := flags.String("config", "glance.yml", "Set config path") err := flags.Parse(os.Args[1:]) if err != nil { @@ -73,6 +78,14 @@ func parseCliOptions() (*cliOptions, error) { intent = cliIntentSensorsPrint } else if args[0] == "diagnose" { intent = cliIntentDiagnose + } else if args[0] == "secret:make" { + intent = cliIntentSecretMake + } else { + return nil, unknownCommandErr + } + } else if len(args) == 2 { + if args[0] == "password:hash" { + intent = cliIntentPasswordHash } else { return nil, unknownCommandErr } diff --git a/internal/glance/config.go b/internal/glance/config.go index 7957b67..4cd7ba7 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -2,6 +2,7 @@ package glance import ( "bytes" + "errors" "fmt" "html/template" "iter" @@ -30,10 +31,16 @@ type config struct { Server struct { Host string `yaml:"host"` Port uint16 `yaml:"port"` + Proxied bool `yaml:"proxied"` AssetsPath string `yaml:"assets-path"` BaseURL string `yaml:"base-url"` } `yaml:"server"` + Auth struct { + SecretKey string `yaml:"secret-key"` + Users map[string]*user `yaml:"users"` + } `yaml:"auth"` + Document struct { Head template.HTML `yaml:"head"` } `yaml:"document"` @@ -59,6 +66,12 @@ type config struct { Pages []page `yaml:"pages"` } +type user struct { + Password string `yaml:"password"` + PasswordHashString string `yaml:"password-hash"` + PasswordHash []byte `yaml:"-"` +} + type page struct { Title string `yaml:"name"` Slug string `yaml:"slug"` @@ -422,11 +435,39 @@ func configFilesWatcher( }, nil } +// TODO: Refactor, we currently validate in two different places, this being +// one of them, which doesn't modify the data and only checks for logical errors +// and then again when creating the application which does modify the data and do +// further validation. Would be better if validation was done in a single place. func isConfigStateValid(config *config) error { if len(config.Pages) == 0 { return fmt.Errorf("no pages configured") } + if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" { + return fmt.Errorf("secret-key must be set when users are configured") + } + + for username := range config.Auth.Users { + if username == "" { + return fmt.Errorf("user has no name") + } + + if len(username) < 3 { + return errors.New("usernames must be at least 3 characters") + } + + user := config.Auth.Users[username] + + if user.Password == "" { + if user.PasswordHashString == "" { + return fmt.Errorf("user %s must have a password or a password-hash set", username) + } + } else if len(user.Password) < 6 { + return fmt.Errorf("the password for %s must be at least 6 characters", username) + } + } + if config.Server.AssetsPath != "" { if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) { return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath) diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 6f5beb2..5368192 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -3,24 +3,30 @@ package glance import ( "bytes" "context" + "encoding/base64" "fmt" "log" "net/http" "path/filepath" + "slices" "strconv" "strings" "sync" "time" + + "golang.org/x/crypto/bcrypt" ) var ( - pageTemplate = mustParseTemplate("page.html", "document.html") + pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html") pageContentTemplate = mustParseTemplate("page-content.html") manifestTemplate = mustParseTemplate("manifest.json") ) const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour +var reservedPageSlugs = []string{"login", "logout"} + type application struct { Version string CreatedAt time.Time @@ -30,6 +36,12 @@ type application struct { slugToPage map[string]*page widgetByID map[uint64]widget + + RequiresAuth bool + authSecretKey []byte + usernameHashToUsername map[string]string + authAttemptsMu sync.Mutex + failedAuthAttempts map[string]*failedAuthAttempt } func newApplication(c *config) (*application, error) { @@ -42,10 +54,47 @@ func newApplication(c *config) (*application, error) { } config := &app.Config - app.slugToPage[""] = &config.Pages[0] + // + // Init auth + // - providers := &widgetProviders{ - assetResolver: app.StaticAssetPath, + if len(config.Auth.Users) > 0 { + secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey) + if err != nil { + return nil, fmt.Errorf("decoding secret-key: %v", err) + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + app.usernameHashToUsername = make(map[string]string) + app.failedAuthAttempts = make(map[string]*failedAuthAttempt) + app.RequiresAuth = true + + for username := range config.Auth.Users { + user := config.Auth.Users[username] + usernameHash, err := computeUsernameHash(username, secretBytes) + if err != nil { + return nil, fmt.Errorf("computing username hash for user %s: %v", username, err) + } + app.usernameHashToUsername[string(usernameHash)] = username + + if user.PasswordHashString != "" { + user.PasswordHash = []byte(user.PasswordHashString) + user.PasswordHashString = "" + } else { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hashing password for user %s: %v", username, err) + } + + user.Password = "" + user.PasswordHash = hashedPassword + } + } + + app.authSecretKey = secretBytes } // @@ -89,6 +138,16 @@ func newApplication(c *config) (*application, error) { return nil, fmt.Errorf("initializing default theme: %v", err) } + // + // Init pages + // + + app.slugToPage[""] = &config.Pages[0] + + providers := &widgetProviders{ + assetResolver: app.StaticAssetPath, + } + for p := range config.Pages { page := &config.Pages[p] page.PrimaryColumnIndex = -1 @@ -97,6 +156,10 @@ func newApplication(c *config) (*application, error) { page.Slug = titleToSlug(page.Title) } + if slices.Contains(reservedPageSlugs, page.Slug) { + return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug) + } + app.slugToPage[page.Slug] = page if page.Width == "default" { @@ -151,7 +214,7 @@ func newApplication(c *config) (*application, error) { config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex } - manifest, err := executeTemplateToString(manifestTemplate, pageTemplateData{App: app}) + manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app}) if err != nil { return nil, fmt.Errorf("parsing manifest.json: %v", err) } @@ -193,17 +256,17 @@ func (a *application) resolveUserDefinedAssetPath(path string) string { return path } -type pageTemplateRequestData struct { +type templateRequestData struct { Theme *themeProperties } -type pageTemplateData struct { +type templateData struct { App *application Page *page - Request pageTemplateRequestData + Request templateRequestData } -func (a *application) populateTemplateRequestData(data *pageTemplateRequestData, r *http.Request) { +func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) { theme := &a.Config.Theme.themeProperties selectedTheme, err := r.Cookie("theme") @@ -219,13 +282,16 @@ func (a *application) populateTemplateRequestData(data *pageTemplateRequestData, func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] - if !exists { a.handleNotFound(w, r) return } - data := pageTemplateData{ + if a.handleUnauthorizedResponse(w, r, redirectToLogin) { + return + } + + data := templateData{ Page: page, App: a, } @@ -244,13 +310,16 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] - if !exists { a.handleNotFound(w, r) return } - pageData := pageTemplateData{ + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + + pageData := templateData{ Page: page, } @@ -274,6 +343,35 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re w.Write(responseBytes.Bytes()) } +func (a *application) addressOfRequest(r *http.Request) string { + remoteAddrWithoutPort := func() string { + for i := len(r.RemoteAddr) - 1; i >= 0; i-- { + if r.RemoteAddr[i] == ':' { + return r.RemoteAddr[:i] + } + } + + return r.RemoteAddr + } + + if !a.Config.Server.Proxied { + return remoteAddrWithoutPort() + } + + // This should probably be configurable or look for multiple headers, not just this one + forwardedFor := r.Header.Get("X-Forwarded-For") + if forwardedFor == "" { + return remoteAddrWithoutPort() + } + + ips := strings.Split(forwardedFor, ",") + if len(ips) == 0 { + return remoteAddrWithoutPort() + } + + return ips[0] +} + func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) @@ -281,22 +379,26 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { } func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { - widgetValue := r.PathValue("widget") + // TODO: this requires a rework of the widget update logic so that rather + // than locking the entire page we lock individual widgets + w.WriteHeader(http.StatusNotImplemented) - widgetID, err := strconv.ParseUint(widgetValue, 10, 64) - if err != nil { - a.handleNotFound(w, r) - return - } + // widgetValue := r.PathValue("widget") - widget, exists := a.widgetByID[widgetID] + // widgetID, err := strconv.ParseUint(widgetValue, 10, 64) + // if err != nil { + // a.handleNotFound(w, r) + // return + // } - if !exists { - a.handleNotFound(w, r) - return - } + // widget, exists := a.widgetByID[widgetID] - widget.handleRequest(w, r) + // if !exists { + // a.handleNotFound(w, r) + // return + // } + + // widget.handleRequest(w, r) } func (a *application) StaticAssetPath(asset string) string { @@ -309,8 +411,6 @@ func (a *application) VersionedAssetPath(asset string) string { } func (a *application) server() (func() error, func() error) { - // TODO: add gzip support, static files must have their gzipped contents cached - // TODO: add HTTPS support mux := http.NewServeMux() mux.HandleFunc("GET /{$}", a.handlePageRequest) @@ -323,6 +423,12 @@ func (a *application) server() (func() error, func() error) { w.WriteHeader(http.StatusOK) }) + if a.RequiresAuth { + mux.HandleFunc("GET /login", a.handleLoginPageRequest) + mux.HandleFunc("GET /logout", a.handleLogoutRequest) + mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt) + } + mux.Handle( fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), http.StripPrefix( diff --git a/internal/glance/main.go b/internal/glance/main.go index 67a980c..6d73a83 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "os" + + "golang.org/x/crypto/bcrypt" ) var buildVersion = "dev" @@ -55,12 +57,43 @@ func Main() int { return cliMountpointInfo(options.args[1]) case cliIntentDiagnose: runDiagnostic() + case cliIntentSecretMake: + key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) + if err != nil { + fmt.Printf("Failed to make secret key: %v\n", err) + return 1 + } + + fmt.Println(key) + case cliIntentPasswordHash: + password := options.args[1] + + if password == "" { + fmt.Println("Password cannot be empty") + return 1 + } + + if len(password) < 6 { + fmt.Println("Password must be at least 6 characters long") + return 1 + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + fmt.Printf("Failed to hash password: %v\n", err) + return 1 + } + + fmt.Println(string(hashedPassword)) } return 0 } func serveApp(configPath string) error { + // TODO: refactor if this gets any more complex, the current implementation is + // difficult to reason about due to all of the callbacks and simultaneous operations, + // use a single goroutine and a channel to initiate synchronous changes to the server exitChannel := make(chan struct{}) hadValidConfigOnStartup := false var stopServer func() error @@ -79,16 +112,23 @@ func serveApp(configPath string) error { } return - } else if !hadValidConfigOnStartup { - hadValidConfigOnStartup = true } app, err := newApplication(config) if err != nil { log.Printf("Failed to create application: %v", err) + + if !hadValidConfigOnStartup { + close(exitChannel) + } + return } + if !hadValidConfigOnStartup { + hadValidConfigOnStartup = true + } + if stopServer != nil { if err := stopServer(); err != nil { log.Printf("Error while trying to stop server: %v", err) diff --git a/internal/glance/static/css/login.css b/internal/glance/static/css/login.css new file mode 100644 index 0000000..0afcd1a --- /dev/null +++ b/internal/glance/static/css/login.css @@ -0,0 +1,155 @@ +.login-bounds { + max-width: 500px; + padding: 0 2rem; +} + +.form-label { + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.form-input { + transition: border-color .2s; +} + +.form-input input { + border: 0; + background: none; + width: 100%; + height: 5.2rem; + font: inherit; + outline: none; + color: var(--color-text-highlight); +} + +.form-input-icon { + width: 2rem; + height: 2rem; + margin-top: -0.1rem; + opacity: 0.5; +} + +.form-input input[type="password"] { + letter-spacing: 0.3rem; + font-size: 0.9em; +} + +.form-input input[type="password"]::placeholder { + letter-spacing: 0; + font-size: var(--font-size-base); +} + +.form-input:hover { + border-color: var(--color-progress-border); +} + +.form-input:focus-within { + border-color: var(--color-primary); + transition-duration: .7s; +} + +.login-button { + width: 100%; + display: block; + padding: 1rem; + background: none; + border: 1px solid var(--color-text-subdue); + border-radius: var(--border-radius); + color: var(--color-text-paragraph); + cursor: pointer; + font: inherit; + font-size: var(--font-size-h4); + display: flex; + gap: .5rem; + align-items: center; + justify-content: center; + transition: all .3s, margin-top 0s; + margin-top: 3rem; +} + +.login-button:not(:disabled) { + box-shadow: 0 0 10px 1px var(--color-separator); +} + +.login-error-message:not(:empty) + .login-button { + margin-top: 2rem; +} + +.login-button:focus, .login-button:hover { + outline: none; + border-color: var(--color-primary); + color: var(--color-primary); +} + +.login-button:disabled { + border-color: var(--color-separator); + color: var(--color-text-subdue); + cursor: not-allowed; +} + +.login-button svg { + width: 1.7rem; + height: 1.7rem; + transition: transform .2s; +} + +.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg { + transform: translateX(.5rem); +} + +.animate-entrance { + animation: fieldReveal 0.7s backwards; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +.animate-entrance:nth-child(1) { animation-delay: .1s; } +.animate-entrance:nth-child(2) { animation-delay: .2s; } +.animate-entrance:nth-child(4) { animation-delay: .3s; } + +@keyframes fieldReveal { + from { + opacity: 0.0001; + transform: translateY(4rem); + } +} + +.login-error-message { + color: var(--color-negative); + font-size: var(--font-size-base); + padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px); + position: relative; + margin-top: 2rem; + animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes errorMessageEntrance { + from { + opacity: 0; + transform: scale(1.1); + } +} + +.login-error-message:empty { + display: none; +} + +.login-error-message::before { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--border-radius); + background: var(--color-negative); + opacity: 0.05; + z-index: -1; +} + +.footer { + animation-delay: .4s; + animation-duration: 1s; +} + +.toggle-password-visibility { + background: none; + border: none; + cursor: pointer; +} diff --git a/internal/glance/static/css/mobile.css b/internal/glance/static/css/mobile.css index 20840d3..2b71a24 100644 --- a/internal/glance/static/css/mobile.css +++ b/internal/glance/static/css/mobile.css @@ -49,9 +49,10 @@ } .mobile-navigation-actions > * { - padding-block: .9rem; + padding-block: 1.1rem; padding-inline: var(--content-bounds-padding); cursor: pointer; + transition: background-color 50ms; } .mobile-navigation-actions > *:active { diff --git a/internal/glance/static/css/site.css b/internal/glance/static/css/site.css index 5055a74..d2fdfcd 100644 --- a/internal/glance/static/css/site.css +++ b/internal/glance/static/css/site.css @@ -153,7 +153,9 @@ body { @keyframes loadingContainerEntrance { from { - opacity: 0; + /* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */ + /* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */ + opacity: 0.001; } } @@ -297,6 +299,17 @@ kbd:active { color: var(--color-text-highlight); } +.logout-button { + width: 2rem; + height: 2rem; + stroke: var(--color-text-subdue); + transition: stroke .2s; +} + +.logout-button:hover, .logout-button:focus { + stroke: var(--color-text-highlight); +} + .theme-choices { --presets-per-row: 2; display: grid; diff --git a/internal/glance/static/js/login.js b/internal/glance/static/js/login.js new file mode 100644 index 0000000..c359c61 --- /dev/null +++ b/internal/glance/static/js/login.js @@ -0,0 +1,128 @@ +import { find } from "./templating.js"; + +const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate"; + +const showPasswordSVG = ` + +`; + +const hidePasswordSVG = ` + + +`; + +const container = find("#login-container"); +const usernameInput = find("#username"); +const passwordInput = find("#password"); +const errorMessage = find("#error-message"); +const loginButton = find("#login-button"); +const toggleVisibilityButton = find("#toggle-password-visibility"); + +const state = { + lastUsername: "", + lastPassword: "", + isLoading: false, + isRateLimited: false +}; + +const lang = { + showPassword: "Show password", + hidePassword: "Hide password", + incorrectCredentials: "Incorrect username or password", + rateLimited: "Too many login attempts, try again in a few minutes", + unknownError: "An error occurred, please try again", +}; + +container.clearStyles("display"); +setTimeout(() => usernameInput.focus(), 200); + +toggleVisibilityButton + .html(showPasswordSVG) + .attr("title", lang.showPassword) + .on("click", function() { + if (passwordInput.type === "password") { + passwordInput.type = "text"; + toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword); + return; + } + + passwordInput.type = "password"; + toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword); + }); + +function enableLoginButtonIfCriteriaMet() { + const usernameValue = usernameInput.value.trim(); + const passwordValue = passwordInput.value.trim(); + + const usernameValid = usernameValue.length >= 3; + const passwordValid = passwordValue.length >= 6; + + const isUsingLastCredentials = + usernameValue === state.lastUsername + && passwordValue === state.lastPassword; + + loginButton.disabled = !( + usernameValid + && passwordValid + && !isUsingLastCredentials + && !state.isLoading + && !state.isRateLimited + ); +} + +usernameInput.on("input", enableLoginButtonIfCriteriaMet); +passwordInput.on("input", enableLoginButtonIfCriteriaMet); + +async function handleLoginAttempt() { + state.lastUsername = usernameInput.value; + state.lastPassword = passwordInput.value; + errorMessage.text(""); + + loginButton.disable(); + state.isLoading = true; + + const response = await fetch(AUTH_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: usernameInput.value, + password: passwordInput.value + }), + }); + + state.isLoading = false; + if (response.status === 200) { + container.animate({ + keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }], + options: { duration: 300, easing: "ease", fill: "forwards" }} + ); + + find("footer")?.animate({ + keyframes: [{ offset: 1, opacity: 0 }], + options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 } + }); + + setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300); + } else if (response.status === 401) { + errorMessage.text(lang.incorrectCredentials); + passwordInput.focus(); + } else if (response.status === 429) { + errorMessage.text(lang.rateLimited); + state.isRateLimited = true; + const retryAfter = response.headers.get("Retry-After") || 30; + setTimeout(() => { + state.lastUsername = ""; + state.lastPassword = ""; + state.isRateLimited = false; + + enableLoginButtonIfCriteriaMet(); + }, retryAfter * 1000); + } else { + errorMessage.text(lang.unknownError); + passwordInput.focus(); + } +} + +loginButton.disable().on("click", handleLoginAttempt); diff --git a/internal/glance/static/js/main.js b/internal/glance/static/js/page.js similarity index 100% rename from internal/glance/static/js/main.js rename to internal/glance/static/js/page.js diff --git a/internal/glance/static/js/templating.js b/internal/glance/static/js/templating.js index 05824bc..14598d0 100644 --- a/internal/glance/static/js/templating.js +++ b/internal/glance/static/js/templating.js @@ -147,6 +147,22 @@ ep.styles = function(s) { return this; } +ep.clearStyles = function(...props) { + for (let i = 0; i < props.length; i++) + this.style.removeProperty(props[i]); + return this; +} + +ep.disable = function() { + this.disabled = true; + return this; +} + +ep.enable = function() { + this.disabled = false; + return this; +} + const epAnimate = ep.animate; ep.animate = function(anim, callback) { const a = epAnimate.call(this, anim.keyframes, anim.options); diff --git a/internal/glance/templates/document.html b/internal/glance/templates/document.html index d964ffa..76332b9 100644 --- a/internal/glance/templates/document.html +++ b/internal/glance/templates/document.html @@ -5,7 +5,7 @@ - + + {{ if .App.Config.Theme.CustomCSSFile }}{{ end }} {{ block "document-head-after" . }}{{ end }} + {{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ template "document-body" . }} diff --git a/internal/glance/templates/footer.html b/internal/glance/templates/footer.html new file mode 100644 index 0000000..5a76330 --- /dev/null +++ b/internal/glance/templates/footer.html @@ -0,0 +1,11 @@ +{{ if not .App.Config.Branding.HideFooter }} + +{{ end }} diff --git a/internal/glance/templates/login.html b/internal/glance/templates/login.html new file mode 100644 index 0000000..6f1c4d8 --- /dev/null +++ b/internal/glance/templates/login.html @@ -0,0 +1,53 @@ +{{- template "document.html" . }} + +{{- define "document-title" }}Login{{ end }} + +{{- define "document-head-before" }} + + +{{- end }} + +{{- define "document-head-after" }} + + +{{- end }} + +{{- define "document-body" }} +
+
+

Login

+
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ + + + +
+
+ {{ template "footer.html" . }} +
+{{- end }} diff --git a/internal/glance/templates/page.html b/internal/glance/templates/page.html index 9a2b8e9..4fb1b41 100644 --- a/internal/glance/templates/page.html +++ b/internal/glance/templates/page.html @@ -3,11 +3,7 @@ {{ define "document-title" }}{{ .Page.Title }}{{ end }} {{ define "document-head-after" }} -{{ if ne "" .App.Config.Theme.CustomCSSFile }} - -{{ end }} - -{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} + {{ end }} {{ define "navigation-links" }} @@ -19,12 +15,12 @@ {{ define "document-body" }}
{{ if not .Page.HideDesktopNavigation }} -
+
+ {{- if .App.RequiresAuth }} + + + + + + {{- end }}
{{ end }} @@ -84,32 +87,30 @@
+ + {{ if .App.RequiresAuth }} + +
Logout
+ + + +
+ {{ end }} -
+

{{ .Page.Title }}

-
Loading
+
Loading
- {{ if not .App.Config.Branding.HideFooter }} -
- {{ if eq "" .App.Config.Branding.CustomFooter }} -
- Glance {{ if ne "dev" .App.Version }}{{ .App.Version }}{{ else }}({{ .App.Version }}){{ end }} -
- {{ else }} - {{ .App.Config.Branding.CustomFooter }} - {{ end }} -
- {{ end }} - + {{ template "footer.html" . }}
{{ end }}