Files
rmm-openwrt/server/internal/httpapi/accounts.go

218 lines
7.0 KiB
Go

package httpapi
import (
"net/http"
"regexp"
"strings"
"time"
"rmm-openwrt/server/internal/authn"
"rmm-openwrt/server/internal/model"
"rmm-openwrt/server/internal/store"
)
var (
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,64}$`)
dnsLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
)
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := a.store.ListUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if !decodeJSON(w, r, &req) {
return
}
req.Username = strings.TrimSpace(req.Username)
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
if req.Role == "" {
req.Role = "user"
}
if !usernamePattern.MatchString(req.Username) {
writeError(w, http.StatusBadRequest, "username must be 3-64 characters and contain only letters, digits, '.', '_' or '-'")
return
}
if req.Role != "user" && req.Role != "admin" {
writeError(w, http.StatusBadRequest, "role must be user or admin")
return
}
passwordHash, err := authn.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := a.store.CreateUser(r.Context(), req.Username, passwordHash, req.Role)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
writeError(w, http.StatusConflict, "username is already in use")
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
principal, _ := principalFromContext(r.Context())
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "user.create", "", "", mustJSON(map[string]any{
"created_user_id": user.ID,
"created_username": user.Username,
"role": user.Role,
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusCreated, user)
}
func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 3 || parts[0] != "api" || parts[1] != "users" || parts[2] == "" {
writeError(w, http.StatusNotFound, "not found")
return
}
var req updateUserRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Disabled == nil && req.Password == "" {
writeError(w, http.StatusBadRequest, "disabled or password is required")
return
}
principal, _ := principalFromContext(r.Context())
if parts[2] == principal.User.ID && req.Disabled != nil && *req.Disabled {
writeError(w, http.StatusBadRequest, "you cannot disable your current account")
return
}
passwordHash := ""
var err error
if req.Password != "" {
passwordHash, err = authn.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
user, found, err := a.store.UpdateUserSecurity(r.Context(), parts[2], req.Disabled, passwordHash)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "last active") {
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
if !found {
writeError(w, http.StatusNotFound, "user not found")
return
}
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "user.security_update", "", "", mustJSON(map[string]any{
"updated_user_id": user.ID,
"disabled": req.Disabled,
"password_reset": passwordHash != "",
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusOK, user)
}
func (a *App) handleCreateEnrollmentGrant(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
var req enrollmentGrantRequest
if !decodeJSON(w, r, &req) {
return
}
label := strings.ToLower(strings.TrimSpace(req.DNSLabel))
if label != "" && !dnsLabelPattern.MatchString(label) {
writeError(w, http.StatusBadRequest, "dns_label must be a valid single DNS label")
return
}
duration := 15 * time.Minute
if req.ExpiresSeconds != 0 {
if req.ExpiresSeconds < 60 || req.ExpiresSeconds > 24*60*60 {
writeError(w, http.StatusBadRequest, "expires_seconds must be between 60 and 86400")
return
}
duration = time.Duration(req.ExpiresSeconds) * time.Second
}
token, err := randomToken(32)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create enrollment grant")
return
}
principal, _ := principalFromContext(r.Context())
grant, err := a.store.CreateEnrollmentGrant(r.Context(), principal.User.ID, label, store.TokenHash(token), time.Now().UTC().Add(duration))
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "limit") {
writeError(w, http.StatusTooManyRequests, "active enrollment grant limit reached")
return
}
if strings.Contains(strings.ToLower(err.Error()), "already") || strings.Contains(strings.ToLower(err.Error()), "unique") {
writeError(w, http.StatusConflict, "dns label is already reserved")
return
}
writeError(w, http.StatusInternalServerError, "failed to create enrollment grant")
return
}
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "enrollment_grant.create", "", "", mustJSON(map[string]any{
"grant_id": grant.ID,
"dns_label": label,
"expires_at": grant.ExpiresAt,
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusCreated, map[string]any{
"grant": grant,
"enrollment_token": token,
})
}
func (a *App) handleUpdateDeviceDNS(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 4 {
writeError(w, http.StatusNotFound, "not found")
return
}
var req dnsLabelRequest
if !decodeJSON(w, r, &req) {
return
}
label := strings.ToLower(strings.TrimSpace(req.DNSLabel))
if !dnsLabelPattern.MatchString(label) {
writeError(w, http.StatusBadRequest, "dns_label must be a valid single DNS label")
return
}
device, found, err := a.store.UpdateDeviceDNSLabel(r.Context(), parts[2], label)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") || strings.Contains(strings.ToLower(err.Error()), "reserved") {
writeError(w, http.StatusConflict, "dns label is already in use")
return
}
writeError(w, http.StatusInternalServerError, "failed to update dns label")
return
}
if !found {
writeError(w, http.StatusNotFound, "device not found")
return
}
a.decorateDevice(&device)
principal, _ := principalFromContext(r.Context())
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "device.dns_update", device.ID, "", mustJSON(map[string]string{
"dns_label": label,
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusOK, device)
}
func (a *App) decorateDevices(devices []model.Device) {
for index := range devices {
a.decorateDevice(&devices[index])
}
}
func (a *App) decorateDevice(device *model.Device) {
if a.deviceDomain != "" && device.DNSLabel != "" {
device.DomainName = device.DNSLabel + "." + a.deviceDomain
}
}