272 lines
6.3 KiB
Go
272 lines
6.3 KiB
Go
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
|
|
} // }}}
|