mirror of
https://github.com/amir20/dozzle.git
synced 2025-12-21 21:33:18 +01:00
225 lines
5.2 KiB
Go
225 lines
5.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/amir20/dozzle/internal/container"
|
|
"github.com/go-chi/jwtauth/v5"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type User struct {
|
|
Username string `json:"username" yaml:"-"`
|
|
Email string `json:"email" yaml:"email"`
|
|
Name string `json:"name" yaml:"name"`
|
|
Password string `json:"-" yaml:"password"`
|
|
Filter string `json:"-" yaml:"filter"`
|
|
ContainerLabels container.ContainerLabels `json:"-" yaml:"-"`
|
|
}
|
|
|
|
func (u User) AvatarURL() string {
|
|
name := u.Name
|
|
if name == "" {
|
|
name = u.Username
|
|
}
|
|
return fmt.Sprintf("https://gravatar.com/avatar/%s?d=https%%3A%%2F%%2Fui-avatars.com%%2Fapi%%2F/%s/128", hashEmail(u.Email), url.QueryEscape(name))
|
|
}
|
|
|
|
func newUser(username, email, name string, labels container.ContainerLabels) User {
|
|
return User{
|
|
Username: username,
|
|
Email: email,
|
|
Name: name,
|
|
ContainerLabels: labels,
|
|
}
|
|
}
|
|
|
|
type UserDatabase struct {
|
|
Users map[string]*User `yaml:"users"`
|
|
LastRead time.Time `yaml:"-"`
|
|
Path string `yaml:"-"`
|
|
}
|
|
|
|
func ReadUsersFromFile(path string) (UserDatabase, error) {
|
|
users, err := decodeUsersFromFile(path)
|
|
if err != nil {
|
|
return users, err
|
|
}
|
|
|
|
users.LastRead = time.Now()
|
|
users.Path = path
|
|
|
|
return users, nil
|
|
}
|
|
|
|
func GenerateUsers(user User, hashPassword bool) *bytes.Buffer {
|
|
buffer := &bytes.Buffer{}
|
|
|
|
if hashPassword {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 11)
|
|
if err != nil {
|
|
log.Fatal().Err(err).Msg("Failed to hash password")
|
|
}
|
|
user.Password = string(hash)
|
|
}
|
|
|
|
users := UserDatabase{
|
|
Users: map[string]*User{
|
|
user.Username: &user,
|
|
},
|
|
}
|
|
|
|
yaml.NewEncoder(buffer).Encode(users)
|
|
|
|
return buffer
|
|
}
|
|
|
|
func decodeUsersFromFile(path string) (UserDatabase, error) {
|
|
users := UserDatabase{}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return users, err
|
|
}
|
|
defer file.Close()
|
|
|
|
if err := yaml.NewDecoder(file).Decode(&users); err != nil {
|
|
return users, err
|
|
}
|
|
|
|
for username, user := range users.Users {
|
|
user.Username = username
|
|
if user.Password == "" {
|
|
log.Fatal().Msgf("User %s has an empty password", username)
|
|
}
|
|
|
|
if !(len(user.Password) == 64 || len(user.Password) == 60) {
|
|
log.Fatal().Str("password", user.Password).Str("user", username).Msg("Invalid password for user")
|
|
}
|
|
|
|
if user.Name == "" {
|
|
user.Name = username
|
|
}
|
|
}
|
|
|
|
return users, nil
|
|
}
|
|
|
|
func (u *UserDatabase) readFileIfChanged() error {
|
|
if u.Path == "" {
|
|
return nil
|
|
}
|
|
info, err := os.Stat(u.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.ModTime().After(u.LastRead) {
|
|
log.Info().Msg("Reloading user database")
|
|
users, err := decodeUsersFromFile(u.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.Users = users.Users
|
|
u.LastRead = time.Now()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *UserDatabase) Find(username string) *User {
|
|
if err := u.readFileIfChanged(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to read user database")
|
|
return nil
|
|
}
|
|
user, ok := u.Users[username]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return user
|
|
}
|
|
|
|
func (u *UserDatabase) FindByPassword(username, password string) *User {
|
|
user := u.Find(username)
|
|
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
|
|
if !CompareHashAndPassword(user.Password, password) {
|
|
return nil
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
func sha256sum(s string) string {
|
|
bytes := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(bytes[:])
|
|
}
|
|
|
|
func CompareHashAndPassword(hash, password string) bool {
|
|
if len(hash) == 64 {
|
|
log.Warn().Msg("Using sha256sum for password comparison. Consider using a more secure hash algorithm to protected against brute-force attacks. See https://github.com/amir20/dozzle/security/advisories/GHSA-w7qr-q9fh-fj35 for more details.")
|
|
return hash == sha256sum(password)
|
|
}
|
|
|
|
if len(hash) == 60 {
|
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
return err == nil
|
|
}
|
|
|
|
log.Error().Str("hash", hash).Msg("Invalid hash length. Expecting 64 or 60 characters.")
|
|
|
|
return false
|
|
}
|
|
|
|
func UserFromContext(ctx context.Context) *User {
|
|
if user, ok := ctx.Value(remoteUser).(User); ok {
|
|
return &user
|
|
} else {
|
|
if _, claims, err := jwtauth.FromContext(ctx); err == nil {
|
|
username, ok := claims["username"].(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if username == "" {
|
|
return nil
|
|
}
|
|
email := claims["email"].(string)
|
|
name := claims["name"].(string)
|
|
containerFilter := container.ContainerLabels{}
|
|
if filter, ok := claims["filter"].(string); ok {
|
|
containerFilter, err = container.ParseContainerFilter(filter)
|
|
if err != nil {
|
|
log.Fatal().Err(err).Str("filter", filter).Msg("Failed to parse container filter")
|
|
}
|
|
}
|
|
|
|
user := newUser(username, email, name, containerFilter)
|
|
return &user
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func RequireAuthentication(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user := UserFromContext(r.Context())
|
|
if user != nil {
|
|
next.ServeHTTP(w, r)
|
|
} else {
|
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
|
}
|
|
})
|
|
}
|