feat(admin): invite people to an account and give them roles
An invitation carries no password. The HQ password is what signs someone into every instance they are granted, so a password the inviter chose would be a shared credential to all of them — the invited row has an empty hash, which cannot authenticate, until /accept-invite sets one. Removing a person revokes every projected instance user first, and refuses outright if any of those is an instance's last owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,9 @@ const BcryptCost = 12
|
||||
// SHA-256 hash stored, 24-hour expiry.
|
||||
const VerifyWindow = 24 * time.Hour
|
||||
|
||||
// CreateCustomerUser creates an unverified self-hosted customer login and emails
|
||||
// the verification link. Called during purchase (spec 5) and by staff.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
|
||||
// CreateCustomerUser creates an unverified HQ login with a chosen password and
|
||||
// emails the verification link. Used by signup and by staff.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,6 +49,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
PasswordHash: string(hash),
|
||||
AccountRole: accountRole,
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
@@ -79,6 +80,91 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateInvitedUser creates a passwordless, unverified member of an existing
|
||||
// account and emails them a link to set a password.
|
||||
//
|
||||
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
|
||||
// never succeed, so the row cannot sign in and cannot usefully be projected
|
||||
// into an instance until the invitee has been through /accept-invite. That is
|
||||
// also why a grant refuses an unverified user.
|
||||
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expiry := time.Now().UTC().Add(VerifyWindow)
|
||||
|
||||
u := models.CustomerUser{
|
||||
UserID: uuid.NewString(),
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
AccountRole: accountRole,
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
|
||||
// Same rollback rule, and the same detached context, as signup: a row
|
||||
// whose link was never delivered can never be signed in to and holds
|
||||
// the unique index on email against the person it was meant for.
|
||||
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
|
||||
log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
|
||||
u.UserID, u.Email, dErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleAcceptInvite consumes an invitation token and sets the password.
|
||||
//
|
||||
// Verification and password-setting are one step for an invitee, because the
|
||||
// link IS the proof of address and there is nothing to verify separately.
|
||||
func HandleAcceptInvite(c *gin.Context) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(body.Token))
|
||||
now := time.Now().UTC()
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified_at": now, "password_hash": string(hash)},
|
||||
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
|
||||
})
|
||||
if err != nil || res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
// HandleSignup creates a self-hosted customer: an account, an unverified user,
|
||||
// and a verification email.
|
||||
//
|
||||
@@ -134,7 +220,7 @@ func HandleSignup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
|
||||
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner); err != nil {
|
||||
// Roll the account back rather than strand one with no owner. Detached
|
||||
// from ctx for the same reason as the user rollback above: a stalled mail
|
||||
// server cancels the request, and a rollback that needs the request to
|
||||
@@ -163,10 +249,28 @@ func HandleVerify(c *gin.Context) {
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
now := time.Now().UTC()
|
||||
ctx := c.Request.Context()
|
||||
hashed := hex.EncodeToString(sum[:])
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
// Peek first. An invited row has no password yet, so consuming its token
|
||||
// here would verify an account nobody can sign in to and burn the only
|
||||
// link that could fix it.
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx, bson.M{
|
||||
"verify_token_hash": hashed,
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
}).Decode(&u); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_hash": hashed,
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
|
||||
@@ -2,8 +2,12 @@ package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const ctxSession = "admin_session_obj"
|
||||
@@ -58,3 +62,50 @@ func RequireCustomer() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
const ctxCustomerUser = "admin_customer_user"
|
||||
|
||||
// CurrentUser returns the calling customer's own row, loaded once per request
|
||||
// by RequireAccountRole.
|
||||
//
|
||||
// It is nil behind RequireCustomer alone. A handler that needs the role must
|
||||
// sit behind RequireAccountRole, which is the only thing that loads it.
|
||||
func CurrentUser(c *gin.Context) *models.CustomerUser {
|
||||
if v, ok := c.Get(ctxCustomerUser); ok {
|
||||
if u, ok := v.(*models.CustomerUser); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireAccountRole admits a customer holding one of the given account roles.
|
||||
//
|
||||
// The role is read from the database on every request rather than carried in
|
||||
// the session. A session lives 24 hours; a demotion that only takes effect
|
||||
// when someone signs out again is not a demotion.
|
||||
func RequireAccountRole(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := Current(c)
|
||||
if s == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
if !slices.Contains(roles, u.AccountRole) {
|
||||
// 403 rather than 404 here: this is the caller's OWN account, so
|
||||
// there is no existence to disclose — the 404 rule protects other
|
||||
// accounts' resources, not the caller's view of their own.
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "your account role does not allow this"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxCustomerUser, &u)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user