mirror of
https://github.com/Xevion/glance.git
synced 2025-12-17 18:12:02 -06:00
Add auth
This commit is contained in:
343
internal/glance/auth.go
Normal file
343
internal/glance/auth.go
Normal file
@@ -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())
|
||||
}
|
||||
85
internal/glance/auth_test.go
Normal file
85
internal/glance/auth_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <pwd> 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
155
internal/glance/static/css/login.css
Normal file
155
internal/glance/static/css/login.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
128
internal/glance/static/js/login.js
Normal file
128
internal/glance/static/js/login.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { find } from "./templating.js";
|
||||
|
||||
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
|
||||
|
||||
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>`;
|
||||
|
||||
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>`;
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<script>
|
||||
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
||||
const pageData = {
|
||||
slug: "{{ .Page.Slug }}",
|
||||
/*{{ if .Page }}*/slug: "{{ .Page.Slug }}",/*{{ end }}*/
|
||||
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
||||
theme: "{{ .Request.Theme.Key }}",
|
||||
};
|
||||
@@ -24,11 +24,10 @@
|
||||
<link rel="icon" type="image/png" href='{{ .App.StaticAssetPath "favicon.png" }}' />
|
||||
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
||||
<script type="module" src='{{ .App.StaticAssetPath "js/main.js" }}'></script>
|
||||
<style id="theme-style">
|
||||
{{ .Request.Theme.CSS }}
|
||||
</style>
|
||||
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
|
||||
{{ if .App.Config.Theme.CustomCSSFile }}<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">{{ end }}
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
{{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "document-body" . }}
|
||||
|
||||
11
internal/glance/templates/footer.html
Normal file
11
internal/glance/templates/footer.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{{ if not .App.Config.Branding.HideFooter }}
|
||||
<footer class="footer flex items-center flex-column">
|
||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||
<div>
|
||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
{{ .App.Config.Branding.CustomFooter }}
|
||||
{{ end }}
|
||||
</footer>
|
||||
{{ end }}
|
||||
53
internal/glance/templates/login.html
Normal file
53
internal/glance/templates/login.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{{- template "document.html" . }}
|
||||
|
||||
{{- define "document-title" }}Login{{ end }}
|
||||
|
||||
{{- define "document-head-before" }}
|
||||
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
|
||||
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
|
||||
{{- end }}
|
||||
|
||||
{{- define "document-head-after" }}
|
||||
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
|
||||
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
|
||||
{{- end }}
|
||||
|
||||
{{- define "document-body" }}
|
||||
<div class="flex flex-column body-content">
|
||||
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
|
||||
<h1 class="visually-hidden">Login</h1>
|
||||
<main id="login-container" class="grow login-bounds" style="display: none;">
|
||||
<div class="animate-entrance">
|
||||
<label class="form-label widget-header" for="username">Username</label>
|
||||
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
||||
</svg>
|
||||
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="animate-entrance">
|
||||
<label class="form-label widget-header margin-top-20" for="password">Password</label>
|
||||
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
|
||||
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-error-message" id="error-message"></div>
|
||||
|
||||
<button class="login-button animate-entrance" id="login-button">
|
||||
<div>LOGIN</div>
|
||||
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
||||
</div>
|
||||
{{- end }}
|
||||
@@ -3,11 +3,7 @@
|
||||
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
||||
|
||||
{{ define "document-head-after" }}
|
||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">
|
||||
{{ end }}
|
||||
|
||||
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
||||
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "navigation-links" }}
|
||||
@@ -19,12 +15,12 @@
|
||||
{{ define "document-body" }}
|
||||
<div class="flex flex-column body-content">
|
||||
{{ if not .Page.HideDesktopNavigation }}
|
||||
<div class="header-container content-bounds{{ if ne "" .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
|
||||
<div class="header-container content-bounds{{ if .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
|
||||
<div class="header flex padding-inline-widget widget-content-frame">
|
||||
<div class="logo" aria-hidden="true">
|
||||
{{- if .App.Config.Branding.LogoURL }}
|
||||
<img src="{{ .App.Config.Branding.LogoURL }}" alt="">
|
||||
{{- else if ne "" .App.Config.Branding.LogoText }}
|
||||
{{- else if .App.Config.Branding.LogoText }}
|
||||
{{- .App.Config.Branding.LogoText }}
|
||||
{{- else }}
|
||||
<svg style="max-height: 2rem;" width="100%" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -37,7 +33,7 @@
|
||||
<nav class="nav flex grow hide-scrollbars">
|
||||
{{ template "navigation-links" . }}
|
||||
</nav>
|
||||
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0" data-popover-offset="0.7">
|
||||
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0">
|
||||
<div class="current-theme-preview">
|
||||
{{ .Request.Theme.PreviewHTML }}
|
||||
</div>
|
||||
@@ -50,6 +46,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{- if .App.RequiresAuth }}
|
||||
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
||||
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||
</svg>
|
||||
</a>
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -84,32 +87,30 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .App.RequiresAuth }}
|
||||
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
||||
<div class="size-h3">Logout</div>
|
||||
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||
</svg>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-bounds grow{{ if ne "" .Page.Width }} content-bounds-{{ .Page.Width }} {{ end }}">
|
||||
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
|
||||
<main class="page{{ if .Page.CenterVertically }} page-center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
|
||||
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
<div class="page-loading-container">
|
||||
<div class="visually-hidden">Loading</div>
|
||||
<div class="visually-hidden">Loading</div>
|
||||
<div class="loading-icon" aria-hidden="true"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{ if not .App.Config.Branding.HideFooter }}
|
||||
<footer class="footer flex items-center flex-column">
|
||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||
<div>
|
||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
{{ .App.Config.Branding.CustomFooter }}
|
||||
{{ end }}
|
||||
</footer>
|
||||
{{ end }}
|
||||
|
||||
{{ template "footer.html" . }}
|
||||
<div class="mobile-navigation-offset"></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user