Notes2/authentication/pkg.go
Magnus Åhall 9a164b984a wip
2024-11-29 09:15:42 +01:00

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
} // }}}