feat(auth): add auth hash (allows purging sessions & more)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
This commit is contained in:
parent
9792a6ff19
commit
386b1c46f8
34
api/api.go
34
api/api.go
@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@ -20,23 +19,26 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/config"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/utils"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
db *database.DBManager
|
||||
cfg *config.Config
|
||||
assets *embed.FS
|
||||
templates map[string]*template.Template
|
||||
httpServer *http.Server
|
||||
db *database.DBManager
|
||||
cfg *config.Config
|
||||
assets *embed.FS
|
||||
httpServer *http.Server
|
||||
templates map[string]*template.Template
|
||||
userAuthCache map[string]string
|
||||
}
|
||||
|
||||
var htmlPolicy = bluemonday.StrictPolicy()
|
||||
|
||||
func NewApi(db *database.DBManager, c *config.Config, assets *embed.FS) *API {
|
||||
api := &API{
|
||||
db: db,
|
||||
cfg: c,
|
||||
assets: assets,
|
||||
db: db,
|
||||
cfg: c,
|
||||
assets: assets,
|
||||
userAuthCache: make(map[string]string),
|
||||
}
|
||||
|
||||
// Create Router
|
||||
@ -63,7 +65,7 @@ func NewApi(db *database.DBManager, c *config.Config, assets *embed.FS) *API {
|
||||
newToken = []byte(c.CookieAuthKey)
|
||||
} else {
|
||||
log.Info("Generating cookie auth key")
|
||||
newToken, err = generateToken(64)
|
||||
newToken, err = utils.GenerateToken(64)
|
||||
if err != nil {
|
||||
log.Panic("Unable to generate cookie auth key")
|
||||
}
|
||||
@ -102,8 +104,8 @@ func NewApi(db *database.DBManager, c *config.Config, assets *embed.FS) *API {
|
||||
|
||||
func (api *API) Start() error {
|
||||
return api.httpServer.ListenAndServe()
|
||||
|
||||
}
|
||||
|
||||
func (api *API) Stop() error {
|
||||
// Stop Server
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@ -115,7 +117,6 @@ func (api *API) Stop() error {
|
||||
|
||||
// Close DB
|
||||
return api.db.DB.Close()
|
||||
|
||||
}
|
||||
|
||||
func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
||||
@ -312,12 +313,3 @@ func apiLogger() gin.HandlerFunc {
|
||||
log.WithFields(logData).Info(fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path))
|
||||
}
|
||||
}
|
||||
|
||||
func generateToken(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
@ -1453,15 +1453,10 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
|
||||
return
|
||||
}
|
||||
|
||||
// Close DB
|
||||
err = api.db.DB.Close()
|
||||
if err != nil {
|
||||
appErrorPage(c, http.StatusInternalServerError, "Unable to close DB.")
|
||||
log.Panic("Unable to close DB: ", err)
|
||||
}
|
||||
|
||||
// Reinit DB
|
||||
api.db.Reload()
|
||||
if err := api.db.Reload(); err != nil {
|
||||
log.Panicf("Unable to reload DB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) restoreData(zipReader *zip.Reader) error {
|
||||
|
91
api/auth.go
91
api/auth.go
@ -12,12 +12,14 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/utils"
|
||||
)
|
||||
|
||||
// Authorization Data
|
||||
type authData struct {
|
||||
UserName string
|
||||
IsAdmin bool
|
||||
AuthHash string
|
||||
}
|
||||
|
||||
// KOSync API Auth Headers
|
||||
@ -41,9 +43,13 @@ func (api *API) authorizeCredentials(username string, password string) (auth *au
|
||||
return
|
||||
}
|
||||
|
||||
// Update Auth Cache
|
||||
api.userAuthCache[user.ID] = user.AuthHash
|
||||
|
||||
return &authData{
|
||||
UserName: user.ID,
|
||||
IsAdmin: user.Admin,
|
||||
AuthHash: user.AuthHash,
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +57,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Check Session First
|
||||
if auth, ok := getSession(session); ok == true {
|
||||
if auth, ok := api.getSession(session); ok == true {
|
||||
c.Set("Authorization", auth)
|
||||
c.Header("Cache-Control", "private")
|
||||
c.Next()
|
||||
@ -76,7 +82,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := setSession(session, *authData); err != nil {
|
||||
if err := api.setSession(session, *authData); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
@ -114,7 +120,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Check Session
|
||||
if auth, ok := getSession(session); ok == true {
|
||||
if auth, ok := api.getSession(session); ok == true {
|
||||
c.Set("Authorization", auth)
|
||||
c.Header("Cache-Control", "private")
|
||||
c.Next()
|
||||
@ -163,7 +169,7 @@ func (api *API) appAuthFormLogin(c *gin.Context) {
|
||||
|
||||
// Set Session
|
||||
session := sessions.Default(c)
|
||||
if err := setSession(session, *authData); err != nil {
|
||||
if err := api.setSession(session, *authData); err != nil {
|
||||
templateVars["Error"] = "Invalid Credentials"
|
||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||
return
|
||||
@ -199,9 +205,20 @@ func (api *API) appAuthFormRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate Auth Hash
|
||||
rawAuthHash, err := utils.GenerateToken(64)
|
||||
if err != nil {
|
||||
log.Error("Failed to generate user token: ", err)
|
||||
templateVars["Error"] = "Failed to Create User"
|
||||
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||
return
|
||||
}
|
||||
|
||||
// Create User in DB
|
||||
rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
|
||||
ID: username,
|
||||
Pass: &hashedPassword,
|
||||
ID: username,
|
||||
Pass: &hashedPassword,
|
||||
AuthHash: fmt.Sprintf("%x", rawAuthHash),
|
||||
})
|
||||
|
||||
// SQL Error
|
||||
@ -233,9 +250,10 @@ func (api *API) appAuthFormRegister(c *gin.Context) {
|
||||
auth := authData{
|
||||
UserName: user.ID,
|
||||
IsAdmin: user.Admin,
|
||||
AuthHash: user.AuthHash,
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
if err := setSession(session, auth); err != nil {
|
||||
if err := api.setSession(session, auth); err != nil {
|
||||
appErrorPage(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
return
|
||||
}
|
||||
@ -251,12 +269,13 @@ func (api *API) appAuthLogout(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
func getSession(session sessions.Session) (auth authData, ok bool) {
|
||||
// Check Session
|
||||
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
|
||||
// Get Session
|
||||
authorizedUser := session.Get("authorizedUser")
|
||||
isAdmin := session.Get("isAdmin")
|
||||
expiresAt := session.Get("expiresAt")
|
||||
if authorizedUser == nil || isAdmin == nil || expiresAt == nil {
|
||||
authHash := session.Get("authHash")
|
||||
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@ -264,22 +283,70 @@ func getSession(session sessions.Session) (auth authData, ok bool) {
|
||||
auth = authData{
|
||||
UserName: authorizedUser.(string),
|
||||
IsAdmin: isAdmin.(bool),
|
||||
AuthHash: authHash.(string),
|
||||
}
|
||||
|
||||
// Validate Auth Hash
|
||||
correctAuthHash, err := api.getUserAuthHash(auth.UserName)
|
||||
if err != nil || correctAuthHash != auth.AuthHash {
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh
|
||||
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
||||
log.Info("Refreshing Session")
|
||||
setSession(session, auth)
|
||||
api.setSession(session, auth)
|
||||
}
|
||||
|
||||
// Authorized
|
||||
return auth, true
|
||||
}
|
||||
|
||||
func setSession(session sessions.Session, auth authData) error {
|
||||
func (api *API) setSession(session sessions.Session, auth authData) error {
|
||||
// Set Session Cookie
|
||||
session.Set("authorizedUser", auth.UserName)
|
||||
session.Set("isAdmin", auth.IsAdmin)
|
||||
session.Set("expiresAt", time.Now().Unix()+(60*60*24*7))
|
||||
session.Set("authHash", auth.AuthHash)
|
||||
|
||||
return session.Save()
|
||||
}
|
||||
|
||||
func (api *API) getUserAuthHash(username string) (string, error) {
|
||||
// Return Cache
|
||||
if api.userAuthCache[username] != "" {
|
||||
return api.userAuthCache[username], nil
|
||||
}
|
||||
|
||||
// Get DB
|
||||
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
|
||||
if err != nil {
|
||||
log.Error("GetUser DB Error:", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Update Cache
|
||||
api.userAuthCache[username] = user.AuthHash
|
||||
|
||||
return api.userAuthCache[username], nil
|
||||
}
|
||||
|
||||
func (api *API) rotateUserAuthHash(username string) error {
|
||||
// Generate Auth Hash
|
||||
rawAuthHash, err := utils.GenerateToken(64)
|
||||
if err != nil {
|
||||
log.Error("Failed to generate user token: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update User
|
||||
_, err = api.db.Queries.UpdateUser(api.db.Ctx, database.UpdateUserParams{
|
||||
UserID: username,
|
||||
AuthHash: fmt.Sprintf("%x", rawAuthHash),
|
||||
})
|
||||
|
||||
// Update Cache
|
||||
api.userAuthCache[username] = fmt.Sprintf("%x", rawAuthHash)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
_ "modernc.org/sqlite"
|
||||
"reichard.io/antholume/config"
|
||||
_ "reichard.io/antholume/database/migrations"
|
||||
)
|
||||
|
||||
type DBManager struct {
|
||||
@ -36,13 +37,15 @@ func NewMgr(c *config.Config) *DBManager {
|
||||
cfg: c,
|
||||
}
|
||||
|
||||
dbm.init()
|
||||
if err := dbm.init(); err != nil {
|
||||
log.Panic("Unable to init DB")
|
||||
}
|
||||
|
||||
return dbm
|
||||
}
|
||||
|
||||
// Init manager
|
||||
func (dbm *DBManager) init() {
|
||||
func (dbm *DBManager) init() error {
|
||||
if dbm.cfg.DBType == "sqlite" || dbm.cfg.DBType == "memory" {
|
||||
var dbLocation string = ":memory:"
|
||||
if dbm.cfg.DBType == "sqlite" {
|
||||
@ -52,7 +55,8 @@ func (dbm *DBManager) init() {
|
||||
var err error
|
||||
dbm.DB, err = sql.Open("sqlite", dbLocation)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to open DB: %v", err)
|
||||
log.Errorf("Unable to open DB: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Single Open Connection
|
||||
@ -60,22 +64,26 @@ func (dbm *DBManager) init() {
|
||||
|
||||
// Execute DDL
|
||||
if _, err := dbm.DB.Exec(ddl, nil); err != nil {
|
||||
log.Fatalf("Error executing schema: %v", err)
|
||||
log.Errorf("Error executing schema: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Perform Migrations
|
||||
err = dbm.performMigrations()
|
||||
if err != nil && err != goose.ErrNoMigrationFiles {
|
||||
log.Fatalf("Error running DB migrations: %v", err)
|
||||
log.Errorf("Error running DB migrations: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache Tables
|
||||
dbm.CacheTempTables()
|
||||
} else {
|
||||
log.Fatal("Unsupported Database")
|
||||
return fmt.Errorf("unsupported database")
|
||||
}
|
||||
|
||||
dbm.Queries = New(dbm.DB)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload manager (close DB & reinit)
|
||||
@ -87,7 +95,9 @@ func (dbm *DBManager) Reload() error {
|
||||
}
|
||||
|
||||
// Reinit DB
|
||||
dbm.init()
|
||||
if err := dbm.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
63
database/migrations/20240128012356_user_auth_hash.go
Normal file
63
database/migrations/20240128012356_user_auth_hash.go
Normal file
@ -0,0 +1,63 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"reichard.io/antholume/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upUserAuthHash, downUserAuthHash)
|
||||
}
|
||||
|
||||
func upUserAuthHash(ctx context.Context, tx *sql.Tx) error {
|
||||
// Create now column
|
||||
_, err := tx.Exec("ALTER TABLE users ADD COLUMN auth_hash TEXT")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current users
|
||||
rows, err := tx.Query("SELECT id FROM users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Query existing users
|
||||
var users []string
|
||||
for rows.Next() {
|
||||
var user string
|
||||
if err := rows.Scan(&user); err != nil {
|
||||
return err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
// Create auth hash per user
|
||||
for _, user := range users {
|
||||
rawAuthHash, err := utils.GenerateToken(64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||
_, err = tx.Exec("UPDATE users SET auth_hash = ? WHERE id = ?", authHash, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func downUserAuthHash(ctx context.Context, tx *sql.Tx) error {
|
||||
// Drop column
|
||||
_, err := tx.Exec("ALTER users DROP COLUMN auth_hash")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
@ -1,5 +1,9 @@
|
||||
# DB Migrations
|
||||
|
||||
```bash
|
||||
# SQL migration
|
||||
goose create migration_name sql
|
||||
|
||||
# Go migration
|
||||
goose create migration_name
|
||||
```
|
||||
|
@ -96,6 +96,7 @@ type Metadatum struct {
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Pass *string `json:"-"`
|
||||
AuthHash string `json:"auth_hash"`
|
||||
Admin bool `json:"-"`
|
||||
TimeOffset *string `json:"time_offset"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
@ -26,8 +26,8 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateUser :execrows
|
||||
INSERT INTO users (id, pass)
|
||||
VALUES (?, ?)
|
||||
INSERT INTO users (id, pass, auth_hash)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- name: DeleteDocument :execrows
|
||||
@ -368,6 +368,7 @@ RETURNING *;
|
||||
UPDATE users
|
||||
SET
|
||||
pass = COALESCE($password, pass),
|
||||
auth_hash = COALESCE($auth_hash, auth_hash),
|
||||
time_offset = COALESCE($time_offset, time_offset)
|
||||
WHERE id = $user_id
|
||||
RETURNING *;
|
||||
|
@ -113,18 +113,19 @@ func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metad
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :execrows
|
||||
INSERT INTO users (id, pass)
|
||||
VALUES (?, ?)
|
||||
INSERT INTO users (id, pass, auth_hash)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string `json:"id"`
|
||||
Pass *string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Pass *string `json:"-"`
|
||||
AuthHash string `json:"auth_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, createUser, arg.ID, arg.Pass)
|
||||
result, err := q.db.ExecContext(ctx, createUser, arg.ID, arg.Pass, arg.AuthHash)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@ -954,7 +955,7 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]Get
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, pass, admin, time_offset, created_at FROM users
|
||||
SELECT id, pass, auth_hash, admin, time_offset, created_at FROM users
|
||||
WHERE id = ?1 LIMIT 1
|
||||
`
|
||||
|
||||
@ -964,6 +965,7 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Pass,
|
||||
&i.AuthHash,
|
||||
&i.Admin,
|
||||
&i.TimeOffset,
|
||||
&i.CreatedAt,
|
||||
@ -1092,7 +1094,7 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
|
||||
}
|
||||
|
||||
const getUsers = `-- name: GetUsers :many
|
||||
SELECT id, pass, admin, time_offset, created_at FROM users
|
||||
SELECT id, pass, auth_hash, admin, time_offset, created_at FROM users
|
||||
`
|
||||
|
||||
func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
|
||||
@ -1107,6 +1109,7 @@ func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Pass,
|
||||
&i.AuthHash,
|
||||
&i.Admin,
|
||||
&i.TimeOffset,
|
||||
&i.CreatedAt,
|
||||
@ -1214,23 +1217,31 @@ const updateUser = `-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET
|
||||
pass = COALESCE(?1, pass),
|
||||
time_offset = COALESCE(?2, time_offset)
|
||||
WHERE id = ?3
|
||||
RETURNING id, pass, admin, time_offset, created_at
|
||||
auth_hash = COALESCE(?2, auth_hash),
|
||||
time_offset = COALESCE(?3, time_offset)
|
||||
WHERE id = ?4
|
||||
RETURNING id, pass, auth_hash, admin, time_offset, created_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
Password *string `json:"-"`
|
||||
AuthHash string `json:"auth_hash"`
|
||||
TimeOffset *string `json:"time_offset"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateUser, arg.Password, arg.TimeOffset, arg.UserID)
|
||||
row := q.db.QueryRowContext(ctx, updateUser,
|
||||
arg.Password,
|
||||
arg.AuthHash,
|
||||
arg.TimeOffset,
|
||||
arg.UserID,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Pass,
|
||||
&i.AuthHash,
|
||||
&i.Admin,
|
||||
&i.TimeOffset,
|
||||
&i.CreatedAt,
|
||||
|
@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
|
||||
pass TEXT NOT NULL,
|
||||
auth_hash TEXT NOT NULL,
|
||||
admin BOOLEAN NOT NULL DEFAULT 0 CHECK (admin IN (0, 1)),
|
||||
time_offset TEXT NOT NULL DEFAULT '0 hours',
|
||||
|
||||
|
@ -3,6 +3,7 @@ package utils
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@ -42,3 +43,13 @@ func CalculatePartialMD5(filePath string) (string, error) {
|
||||
allBytes := buf.Bytes()
|
||||
return fmt.Sprintf("%x", md5.Sum(allBytes)), nil
|
||||
}
|
||||
|
||||
// Creates a token of n size
|
||||
func GenerateToken(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user