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:
parent
7cdc561dcc
commit
8b2d2649ac
4 changed files with 80 additions and 20 deletions
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.workaround.org/chaas/imc/backend/internal/auth"
|
||||
"git.workaround.org/chaas/imc/backend/internal/db"
|
||||
"git.workaround.org/chaas/imc/backend/internal/mail"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -22,7 +23,7 @@ func NewUserHandler(database *db.DB) *UserHandler {
|
|||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Quota int64 `json:"quota"`
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +170,11 @@ func (h *UserHandler) Create(c *gin.Context) {
|
|||
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)
|
||||
if err != nil {
|
||||
|
|
@ -178,7 +183,7 @@ func (h *UserHandler) Create(c *gin.Context) {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand" // cryptographically secure random number generator
|
||||
"errors" // standard errors package
|
||||
"log" // logging
|
||||
"time" // time handling
|
||||
|
||||
"github.com/golang-jwt/jwt/v5" // JWT library for token handling
|
||||
|
|
@ -125,3 +127,16 @@ func CheckPassword(password, hash string) bool {
|
|||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
quotaNull := sql.NullInt64{Int64: quota, Valid: quota > 0}
|
||||
quotaNull := sql.NullInt64{Int64: quota, Valid: true}
|
||||
return d.Queries.CreateUser(ctx, imcdb.CreateUserParams{
|
||||
DomainID: domainID,
|
||||
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 {
|
||||
quotaNull := sql.NullInt64{Int64: quota, Valid: quota > 0}
|
||||
quotaNull := sql.NullInt64{Int64: quota, Valid: true}
|
||||
return d.Queries.UpdateUserQuota(ctx, imcdb.UpdateUserQuotaParams{
|
||||
Quota: quotaNull,
|
||||
ID: id,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
let localPart = $state('');
|
||||
let newUser = $state({ password: '', quota: 0 });
|
||||
let createError = $state('');
|
||||
let createdPassword = $state('');
|
||||
let showCreatedPassword = $state(false);
|
||||
let domainName = $state('');
|
||||
|
||||
async function loadUsers(name: string) {
|
||||
|
|
@ -35,6 +37,8 @@
|
|||
|
||||
function openModal() {
|
||||
createError = '';
|
||||
createdPassword = '';
|
||||
showCreatedPassword = false;
|
||||
localPart = '';
|
||||
newUser = { password: '', quota: 0 };
|
||||
dialogEl?.showModal();
|
||||
|
|
@ -44,6 +48,17 @@
|
|||
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() {
|
||||
createError = '';
|
||||
const email = `${localPart}@${domainName}`;
|
||||
|
|
@ -55,14 +70,15 @@
|
|||
'Content-Type': 'application/json',
|
||||
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) {
|
||||
closeModal();
|
||||
createdPassword = data.password || '';
|
||||
showCreatedPassword = true;
|
||||
await loadUsers(domainName);
|
||||
} else {
|
||||
const error = await res.json();
|
||||
createError = error.error || 'Failed to create user';
|
||||
createError = data.error || 'Failed to create user';
|
||||
}
|
||||
} catch (e) {
|
||||
createError = 'Failed to create user';
|
||||
|
|
@ -70,6 +86,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
await navigator.clipboard.writeText(createdPassword);
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
if (!confirm('Delete this user? Mailbox will NOT be deleted.')) return;
|
||||
try {
|
||||
|
|
@ -191,13 +211,20 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset mt-4">
|
||||
<legend class="fieldset-legend">Password</legend>
|
||||
<legend class="fieldset-legend">Password (leave empty to generate)</legend>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="password"
|
||||
class="input input-bordered w-full"
|
||||
type={showCreatedPassword ? 'text' : 'password'}
|
||||
class="input input-bordered join-item flex-1"
|
||||
bind:value={newUser.password}
|
||||
required
|
||||
/>
|
||||
<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 class="fieldset mt-4">
|
||||
<legend class="fieldset-legend">Quota (bytes, 0 = default)</legend>
|
||||
|
|
@ -208,14 +235,27 @@
|
|||
min="0"
|
||||
/>
|
||||
</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}
|
||||
<div class="alert alert-error mt-4">
|
||||
<span>{createError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeModal}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
<button type="button" class="btn" onclick={closeModal}>Close</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={showCreatedPassword}>Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue