Add random password generation for new users

- Move GenerateRandomPassword to auth package for reuse
- Make password optional in user creation (generates 16-char if empty)
- Return generated password in response for user to copy
- Add Generate/Copy UI with show/hide toggle
This commit is contained in:
Christoph Haas 2026-03-29 14:52:59 +02:00
parent 7cdc561dcc
commit 8b2d2649ac
4 changed files with 80 additions and 20 deletions

View file

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.workaround.org/chaas/imc/backend/internal/auth"
"git.workaround.org/chaas/imc/backend/internal/db" "git.workaround.org/chaas/imc/backend/internal/db"
"git.workaround.org/chaas/imc/backend/internal/mail" "git.workaround.org/chaas/imc/backend/internal/mail"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -22,7 +23,7 @@ func NewUserHandler(database *db.DB) *UserHandler {
type CreateUserRequest struct { type CreateUserRequest struct {
Email string `json:"email" binding:"required"` Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password"`
Quota int64 `json:"quota"` Quota int64 `json:"quota"`
} }
@ -169,7 +170,11 @@ func (h *UserHandler) Create(c *gin.Context) {
return return
} }
passwordHash := "{BLF-CRYPT}" + req.Password password := req.Password
if password == "" {
password = auth.GenerateRandomPassword(16)
}
passwordHash := "{BLF-CRYPT}" + password
err = h.db.CreateUser(ctx, domain.ID, req.Email, passwordHash, req.Quota) err = h.db.CreateUser(ctx, domain.ID, req.Email, passwordHash, req.Quota)
if err != nil { if err != nil {
@ -178,7 +183,7 @@ func (h *UserHandler) Create(c *gin.Context) {
return return
} }
Created(c, map[string]string{"message": "user created"}) Created(c, map[string]interface{}{"message": "user created", "password": password})
} }
func (h *UserHandler) Update(c *gin.Context) { func (h *UserHandler) Update(c *gin.Context) {

View file

@ -3,8 +3,10 @@
package auth package auth
import ( import (
"errors" // standard errors package "crypto/rand" // cryptographically secure random number generator
"time" // time handling "errors" // standard errors package
"log" // logging
"time" // time handling
"github.com/golang-jwt/jwt/v5" // JWT library for token handling "github.com/golang-jwt/jwt/v5" // JWT library for token handling
"golang.org/x/crypto/bcrypt" // bcrypt for secure password hashing "golang.org/x/crypto/bcrypt" // bcrypt for secure password hashing
@ -125,3 +127,16 @@ func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil return err == nil
} }
// GenerateRandomPassword creates a cryptographically secure random password.
func GenerateRandomPassword(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
result := make([]byte, length)
if _, err := rand.Read(result); err != nil {
log.Fatalf("Failed to generate random password: %v", err)
}
for i := range result {
result[i] = charset[int(result[i])%len(charset)]
}
return string(result)
}

View file

@ -25,7 +25,7 @@ func (d *DB) GetUserByEmail(ctx context.Context, email string) (imcdb.VirtualUse
} }
func (d *DB) CreateUser(ctx context.Context, domainID uint32, email, passwordHash string, quota int64) error { func (d *DB) CreateUser(ctx context.Context, domainID uint32, email, passwordHash string, quota int64) error {
quotaNull := sql.NullInt64{Int64: quota, Valid: quota > 0} quotaNull := sql.NullInt64{Int64: quota, Valid: true}
return d.Queries.CreateUser(ctx, imcdb.CreateUserParams{ return d.Queries.CreateUser(ctx, imcdb.CreateUserParams{
DomainID: domainID, DomainID: domainID,
Email: email, Email: email,
@ -42,7 +42,7 @@ func (d *DB) UpdateUserPassword(ctx context.Context, id uint32, passwordHash str
} }
func (d *DB) UpdateUserQuota(ctx context.Context, id uint32, quota int64) error { func (d *DB) UpdateUserQuota(ctx context.Context, id uint32, quota int64) error {
quotaNull := sql.NullInt64{Int64: quota, Valid: quota > 0} quotaNull := sql.NullInt64{Int64: quota, Valid: true}
return d.Queries.UpdateUserQuota(ctx, imcdb.UpdateUserQuotaParams{ return d.Queries.UpdateUserQuota(ctx, imcdb.UpdateUserQuotaParams{
Quota: quotaNull, Quota: quotaNull,
ID: id, ID: id,

View file

@ -14,6 +14,8 @@
let localPart = $state(''); let localPart = $state('');
let newUser = $state({ password: '', quota: 0 }); let newUser = $state({ password: '', quota: 0 });
let createError = $state(''); let createError = $state('');
let createdPassword = $state('');
let showCreatedPassword = $state(false);
let domainName = $state(''); let domainName = $state('');
async function loadUsers(name: string) { async function loadUsers(name: string) {
@ -35,6 +37,8 @@
function openModal() { function openModal() {
createError = ''; createError = '';
createdPassword = '';
showCreatedPassword = false;
localPart = ''; localPart = '';
newUser = { password: '', quota: 0 }; newUser = { password: '', quota: 0 };
dialogEl?.showModal(); dialogEl?.showModal();
@ -44,6 +48,17 @@
dialogEl?.close(); dialogEl?.close();
} }
function generatePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
const array = new Uint8Array(16);
crypto.getRandomValues(array);
for (let i = 0; i < 16; i++) {
password += chars[array[i] % chars.length];
}
newUser.password = password;
}
async function createUser() { async function createUser() {
createError = ''; createError = '';
const email = `${localPart}@${domainName}`; const email = `${localPart}@${domainName}`;
@ -55,14 +70,15 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ email, password: newUser.password, quota: newUser.quota }) body: JSON.stringify({ email, password: newUser.password || undefined, quota: newUser.quota })
}); });
const data = await res.json();
if (res.ok) { if (res.ok) {
closeModal(); createdPassword = data.password || '';
showCreatedPassword = true;
await loadUsers(domainName); await loadUsers(domainName);
} else { } else {
const error = await res.json(); createError = data.error || 'Failed to create user';
createError = error.error || 'Failed to create user';
} }
} catch (e) { } catch (e) {
createError = 'Failed to create user'; createError = 'Failed to create user';
@ -70,6 +86,10 @@
} }
} }
async function copyPassword() {
await navigator.clipboard.writeText(createdPassword);
}
async function deleteUser(id: number) { async function deleteUser(id: number) {
if (!confirm('Delete this user? Mailbox will NOT be deleted.')) return; if (!confirm('Delete this user? Mailbox will NOT be deleted.')) return;
try { try {
@ -191,13 +211,20 @@
</div> </div>
</fieldset> </fieldset>
<fieldset class="fieldset mt-4"> <fieldset class="fieldset mt-4">
<legend class="fieldset-legend">Password</legend> <legend class="fieldset-legend">Password (leave empty to generate)</legend>
<input <div class="join w-full">
type="password" <input
class="input input-bordered w-full" type={showCreatedPassword ? 'text' : 'password'}
bind:value={newUser.password} class="input input-bordered join-item flex-1"
required bind:value={newUser.password}
/> />
<button type="button" class="btn join-item" onclick={() => showCreatedPassword = !showCreatedPassword}>
{showCreatedPassword ? '🙈' : '👁️'}
</button>
<button type="button" class="btn btn-secondary join-item" onclick={generatePassword}>
Generate
</button>
</div>
</fieldset> </fieldset>
<fieldset class="fieldset mt-4"> <fieldset class="fieldset mt-4">
<legend class="fieldset-legend">Quota (bytes, 0 = default)</legend> <legend class="fieldset-legend">Quota (bytes, 0 = default)</legend>
@ -208,14 +235,27 @@
min="0" min="0"
/> />
</fieldset> </fieldset>
{#if showCreatedPassword && createdPassword}
<div class="alert alert-success mt-4">
<div class="flex-1">
<label class="text-sm font-medium">User created! Copy password:</label>
<div class="flex items-center gap-2 mt-1">
<code class="flex-1 bg-base-200 p-2 rounded text-sm break-all">{createdPassword}</code>
<button type="button" class="btn btn-sm" onclick={copyPassword}>
Copy
</button>
</div>
</div>
</div>
{/if}
{#if createError} {#if createError}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<span>{createError}</span> <span>{createError}</span>
</div> </div>
{/if} {/if}
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onclick={closeModal}>Cancel</button> <button type="button" class="btn" onclick={closeModal}>Close</button>
<button type="submit" class="btn btn-primary">Create</button> <button type="submit" class="btn btn-primary" disabled={showCreatedPassword}>Create</button>
</div> </div>
</form> </form>
</div> </div>