package authentication import ( // External _ "git.gibonuddevalla.se/go/wrappederror" "github.com/golang-jwt/jwt/v5" "github.com/jmoiron/sqlx" "github.com/lib/pq" // Standard "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "time" ) type Manager struct { db *sqlx.DB log *slog.Logger secret []byte ExpireDays int } type User struct { ID int Username string Name string } func httpError(w http.ResponseWriter, err error) { // {{{ j, _ := json.Marshal(struct { OK bool Error string }{ false, err.Error(), }) w.Write(j) } // }}} func NewManager(db *sqlx.DB, log *slog.Logger, secret string, expireDays int) (mngr Manager, err error) { // {{{ mngr.db = db mngr.log = log mngr.secret, err = hex.DecodeString(secret) mngr.ExpireDays = expireDays return } // }}} func validateTokenTimestamps(claims jwt.MapClaims) error { // {{{ now := time.Now() if issuedAt, ok := claims["iat"].(float64); ok { if now.Unix() < int64(issuedAt) { return errors.New("Token is not valid yet") } } else { return errors.New("Token is missing iat") } if expires, ok := claims["exp"].(float64); ok { if now.Unix() > int64(expires) { return errors.New("Token has expired") } } return nil } // }}} func (mngr *Manager) GenerateToken(data map[string]any) (string, error) { // {{{ // Create a new token object, specifying signing method and the claims // you would like it to contain. now := time.Now() data["iat"] = now.Unix() data["exp"] = now.Add(time.Hour * 24 * time.Duration(mngr.ExpireDays)).Unix() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(data)) // Sign and get the complete encoded token as a string using the secret. return token.SignedString(mngr.secret) } // }}} func (mngr *Manager) VerifyToken(tokenString string) (jwt.MapClaims, error) { // {{{ // Parse takes the token string and a function for looking up the key. The latter is especially // useful if you use multiple keys for your application. The standard is to use 'kid' in the // head of the token to identify which key to use, but the parsed token (head and claims) is provided // to the callback, providing flexibility. token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // Don't forget to validate the alg is what you expect: if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) } // hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key") return mngr.secret, nil }) if err != nil { mngr.log.Error("authentication", "error", err) return nil, err } if claims, ok := token.Claims.(jwt.MapClaims); ok { err = validateTokenTimestamps(claims) if err != nil { return nil, err } return claims, nil } else { return nil, err } } // }}} func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Request) { // {{{ var request struct { Username string Password string } body, _ := io.ReadAll(r.Body) err := json.Unmarshal(body, &request) if err != nil { mngr.log.Debug("authentication", "error", err) httpError(w, err) return } // Verify username and password against the db user table. authenticated, user, err := mngr.Authenticate(request.Username, request.Password) if err != nil { mngr.log.Error("authentication", "error", err) httpError(w, err) return } if !authenticated { mngr.log.Info("authentication", "username", request.Username, "status", "failed") httpError(w, errors.New("Authentication failed")) return } // A new token is generated with the information. var token string data := make(map[string]any) data["uid"] = user.ID data["login"] = user.Username data["name"] = user.Name token, err = mngr.GenerateToken(data) if err != nil { mngr.log.Error("authentication", "error", err) httpError(w, err) return } mngr.log.Info("authentication", "username", request.Username, "status", "accepted") j, _ := json.Marshal(struct { OK bool User User Token string }{true, user, token}) w.Write(j) } // }}} func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{ var row *sql.Row row = mngr.db.QueryRow(` SELECT id, username, name FROM public.user WHERE LOWER(username) = LOWER($1) AND password = password_hash(SUBSTRING(password FROM 1 FOR 32), $2::bytea) `, username, password, ) err = row.Scan(&user.ID, &user.Username, &user.Name) if err != nil && err.Error() == "sql: no rows in result set" { err = nil authenticated = false return } if err != nil { return } authenticated = user.ID > 0 return } // }}} func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists bool, err error) { // {{{ _, err = mngr.db.Exec(` INSERT INTO public.user(username, password, name) VALUES( $1, public.password_hash( /* salt in hex */ ENCODE(public.gen_random_bytes(16), 'hex'), /* password */ $2::bytea ), $3 ) `, username, password, name, ) if err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { err = errors.New("User already exists") alreadyExists = true return } } return } // }}} func (mngr *Manager) ChangePassword(username, currentPassword, newPassword string, forceChange bool) (changed bool, err error) { // {{{ var res sql.Result if forceChange { res, err = mngr.db.Exec(` UPDATE public.user SET "password" = public.password_hash( /* salt in hex */ ENCODE(public.gen_random_bytes(16), 'hex'), /* password */ $2::bytea ) WHERE username = $1 `, username, newPassword, ) } else { res, err = mngr.db.Exec(` UPDATE public.user SET "password" = public.password_hash( /* salt in hex */ ENCODE(public.gen_random_bytes(16), 'hex'), /* password */ $3::bytea ) WHERE username = $1 AND "password" = public.password_hash(SUBSTRING(password FROM 1 FOR 32), $2::bytea) `, username, currentPassword, newPassword, ) } var rowsAffected int64 rowsAffected, err = res.RowsAffected() if err != nil { return } changed = (rowsAffected == 1) return } // }}}