Fosi Fossil Service
Small teams. Open source. Built on Fossil.

IaC / SwiftFlow

High-performance WMS with wave picking, smart routing, and real-time inventory visibility.

Public | Owner: IaC | Default branch: trunk | Last update: 2026-10-23 14:37

Artifact e7a9417405b9cd1d448c5b64631c97cc6adde28dc3877e337555586b797e366d:


import clock
import config.{type AuthConfig}
import database.{type Database, type DbError}
import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/order
import gleam/result
import gleam/string
import logging
import password
import rbac.{type Scope}
import repo
import user_repo.{type User, AuditEntry}

pub type Principal {
  Principal(
    user_id: String,
    username: String,
    display_name: String,
    roles: List(String),
    permissions: List(String),
    warehouse_ids: List(Int),
    scope: Scope,
    terminal: String,
    current_warehouse: Int,
    token_hash: String,
    expires_at: String,
  )
}

pub type AuthError {
  Unauthorized(String)
  Forbidden(String)
  Rejected(String)
  NotFound(String)
  AuthDb(DbError)
}

pub type Bootstrap {
  AdminCreated(username: String)
  UsersExist
  AdminNotConfigured
}

pub type UserInput {
  UserInput(
    display_name: String,
    enabled: Bool,
    roles: List(String),
    warehouse_ids: List(Int),
  )
}

pub fn has(principal: Principal, permission: String) -> Bool {
  list.contains(principal.permissions, permission)
}

pub fn bootstrap_admin(db: Database, cfg: AuthConfig) -> Result(Bootstrap, String) {
  case database.transaction(db, user_repo.count_users) {
    Error(database.DbError(message)) -> Error(message)
    Ok(n) if n > 0 -> Ok(UsersExist)
    Ok(_) ->
      case normalize_username(cfg.admin_username), cfg.admin_password {
        Error(_), _ | _, "" -> Ok(AdminNotConfigured)
        Ok(username), secret ->
          case password.validate(secret) {
            Error(message) -> Error("SWIFTFLOW_ADMIN_PASSWORD 不合格:" <> message)
            Ok(_) -> {
              let now = clock.now_local()
              let hash = password.hash(secret)
              case database.transaction(db, fn(conn) {
                let id = new_user_id()
                use _ <- result.try(user_repo.insert_user(
                  conn, id, username, "超级管理员", hash, now,
                ))
                user_repo.set_roles(conn, id, [rbac.sys_admin])
              }) {
                Ok(_) -> Ok(AdminCreated(username))
                Error(database.DbError(message)) -> Error(message)
              }
            }
          }
      }
  }
}

pub fn login(
  db: Database,
  cfg: AuthConfig,
  raw_username: String,
  secret: String,
  terminal: String,
) -> Result(#(String, Principal), AuthError) {
  let terminal = string.lowercase(string.trim(terminal))
  use <- guard(
    !list.contains(rbac.terminals, terminal),
    Rejected("terminal 只能是 " <> string.join(rbac.terminals, "、")),
  )
  let username = result.unwrap(normalize_username(raw_username), "")
  use found <- result.try(db_call(db, fn(conn) {
    user_repo.find_credential(conn, username)
  }))
  let now = clock.now_local()
  case found {
    None -> {
      let _ = password.burn(secret)
      audit_raw(db, "", username, "auth.login_failed", username, 0, "账号不存在")
      Error(Unauthorized("账号或密码错误"))
    }
    Some(cred) ->
      case cred.locked_until != "" && string.compare(cred.locked_until, now) == order.Gt {
        True ->
          Error(Unauthorized("密码错误次数过多,账号锁定到 " <> cred.locked_until))
        False ->
          case password.verify(secret, cred.password_hash) {
            False -> {
              let attempts = cred.failed_attempts + 1
              let #(attempts, locked_until) = case attempts >= cfg.max_failed_logins {
                True -> #(0, clock.shift_seconds_local(cfg.lockout_minutes * 60))
                False -> #(attempts, "")
              }
              use _ <- result.try(db_call(db, fn(conn) {
                user_repo.record_login_failure(conn, cred.id, attempts, locked_until, now)
              }))
              audit_raw(db, cred.id, cred.username, "auth.login_failed", cred.username, 0, "密码错误")
              case locked_until {
                "" -> Error(Unauthorized("账号或密码错误"))
                until -> Error(Unauthorized("密码错误次数过多,账号锁定到 " <> until))
              }
            }
            True ->
              case cred.enabled {
                False -> Error(Forbidden("账号已停用"))
                True -> open_session(db, cfg, cred.id, terminal, now)
              }
          }
      }
  }
}

fn open_session(
  db: Database,
  cfg: AuthConfig,
  user_id: String,
  terminal: String,
  now: String,
) -> Result(#(String, Principal), AuthError) {
  use roles <- result.try(db_call(db, fn(conn) {
    use roles <- result.try(user_repo.user_roles(conn, user_id))
    use defs <- result.try(user_repo.list_roles(conn))
    Ok(#(roles, defs))
  }))
  let #(role_codes, defs) = roles
  let allowed =
    list.any(defs, fn(def: user_repo.RoleRow) {
      list.contains(role_codes, def.code) && list.contains(def.terminals, terminal)
    })
  use <- guard(!allowed, Forbidden("当前角色不能从该终端登录"))
  let token = password.random_hex(32)
  let token_hash = password.sha256_hex(token)
  let expires_at = clock.shift_seconds_local(cfg.session_hours * 3600)
  use principal <- result.try(db_call(db, fn(conn) {
    use warehouses <- result.try(user_repo.user_warehouses(conn, user_id))
    let current = case rbac.scope_for(role_codes, warehouses), warehouses {
      rbac.Warehouses(_), [only] -> only
      _, _ -> 0
    }
    use _ <- result.try(user_repo.reset_login_failures(conn, user_id))
    use _ <- result.try(user_repo.delete_expired_sessions(conn, now))
    use _ <- result.try(user_repo.insert_session(
      conn, token_hash, user_id, terminal, current, now, expires_at,
    ))
    load_principal(conn, user_id, terminal, current, token_hash, expires_at)
  }))
  audit(db, principal, "auth.login", principal.username, principal.current_warehouse, terminal)
  Ok(#(token, principal))
}

pub fn authenticate(db: Database, token: String) -> Result(Principal, AuthError) {
  use <- guard(token == "", Unauthorized("请先登录"))
  let token_hash = password.sha256_hex(token)
  let now = clock.now_local()
  use found <- result.try(db_call(db, fn(conn) {
    use session <- result.try(user_repo.find_session(conn, token_hash, now))
    case session {
      None -> Ok(None)
      Some(row) -> {
        use _ <- result.try(user_repo.touch_session(conn, token_hash, now))
        use principal <- result.try(load_principal(
          conn, row.user_id, row.terminal, row.warehouse_id, token_hash, row.expires_at,
        ))
        Ok(Some(principal))
      }
    }
  }))
  case found {
    None -> Error(Unauthorized("登录已失效,请重新登录"))
    Some(principal) -> Ok(principal)
  }
}

fn load_principal(
  conn: database.Conn,
  user_id: String,
  terminal: String,
  current: Int,
  token_hash: String,
  expires_at: String,
) -> Result(Principal, DbError) {
  use user <- result.try(user_repo.get_user(conn, user_id))
  use permissions <- result.try(user_repo.user_permissions(conn, user_id))
  let #(username, display_name, roles, warehouses) = case user {
    Some(u) -> #(u.username, u.display_name, u.roles, u.warehouse_ids)
    None -> #("", "", [], [])
  }
  let scope = rbac.scope_for(roles, warehouses)
  let current = case current > 0 && rbac.allows(scope, current) {
    True -> current
    False -> 0
  }
  Ok(Principal(
    user_id: user_id,
    username: username,
    display_name: display_name,
    roles: roles,
    permissions: permissions,
    warehouse_ids: warehouses,
    scope: scope,
    terminal: terminal,
    current_warehouse: current,
    token_hash: token_hash,
    expires_at: expires_at,
  ))
}

pub fn logout(db: Database, principal: Principal) -> Result(Nil, AuthError) {
  use _ <- result.try(db_call(db, fn(conn) {
    user_repo.delete_session(conn, principal.token_hash)
  }))
  audit(db, principal, "auth.logout", principal.username, principal.current_warehouse, "")
  Ok(Nil)
}

pub fn select_warehouse(
  db: Database,
  principal: Principal,
  warehouse_id: Int,
) -> Result(Principal, AuthError) {
  use <- guard(!rbac.allows(principal.scope, warehouse_id), Forbidden("无权访问该仓库"))
  use found <- result.try(db_call(db, fn(conn) { repo.get_warehouse(conn, warehouse_id) }))
  case found {
    None -> Error(NotFound("仓库不存在"))
    Some(row) if !row.operational -> Error(Rejected("该仓库只作显示,不能作为作业仓库"))
    Some(_) -> {
      use _ <- result.try(db_call(db, fn(conn) {
        user_repo.set_session_warehouse(conn, principal.token_hash, warehouse_id)
      }))
      audit(db, principal, "auth.select_warehouse", int.to_string(warehouse_id), warehouse_id, "")
      Ok(Principal(..principal, current_warehouse: warehouse_id))
    }
  }
}

pub fn change_password(
  db: Database,
  principal: Principal,
  old: String,
  new: String,
) -> Result(Nil, AuthError) {
  use stored <- result.try(db_call(db, fn(conn) {
    user_repo.password_hash_of(conn, principal.user_id)
  }))
  let ok = case stored {
    Some(hash) -> password.verify(old, hash)
    None -> False
  }
  use <- guard(!ok, Rejected("原密码不正确"))
  use new <- result.try(password.validate(new) |> result.map_error(Rejected))
  let hash = password.hash(new)
  use _ <- result.try(db_call(db, fn(conn) {
    use _ <- result.try(user_repo.set_password(conn, principal.user_id, hash, clock.now_local()))
    user_repo.delete_user_sessions(conn, principal.user_id, principal.token_hash)
  }))
  audit(db, principal, "auth.change_password", principal.username, 0, "")
  Ok(Nil)
}

pub fn list_users(db: Database) -> Result(List(User), AuthError) {
  db_call(db, user_repo.list_users)
}

pub fn create_user(
  db: Database,
  actor: Principal,
  raw_username: String,
  secret: String,
  input: UserInput,
) -> Result(User, AuthError) {
  use username <- result.try(normalize_username(raw_username) |> result.map_error(Rejected))
  use secret <- result.try(password.validate(secret) |> result.map_error(Rejected))
  use input <- result.try(validate_input(db, input))
  let hash = password.hash(secret)
  let now = clock.now_local()
  use outcome <- result.try(db_call(db, fn(conn) {
    use taken <- result.try(user_repo.username_taken(conn, username))
    case taken {
      True -> Ok(Error("账号已存在"))
      False -> {
        let id = new_user_id()
        use _ <- result.try(user_repo.insert_user(conn, id, username, input.display_name, hash, now))
        use _ <- result.try(user_repo.update_profile(conn, id, input.display_name, input.enabled, now))
        use _ <- result.try(user_repo.set_roles(conn, id, input.roles))
        use _ <- result.try(user_repo.set_warehouses(conn, id, input.warehouse_ids))
        use user <- result.try(user_repo.get_user(conn, id))
        Ok(option.to_result(user, "用户创建后读取失败"))
      }
    }
  }))
  use user <- result.try(result.map_error(outcome, Rejected))
  audit(db, actor, "user.create", user.username, 0, describe_input(input))
  Ok(user)
}

pub fn update_user(
  db: Database,
  actor: Principal,
  user_id: String,
  input: UserInput,
) -> Result(User, AuthError) {
  use input <- result.try(validate_input(db, input))
  let is_self = user_id == actor.user_id
  use <- guard(is_self && !input.enabled, Rejected("不能停用自己的账号"))
  use <- guard(
    is_self
      && list.contains(actor.roles, rbac.sys_admin)
      && !list.contains(input.roles, rbac.sys_admin),
    Rejected("不能移除自己的超级管理员角色"),
  )
  let now = clock.now_local()
  use found <- result.try(db_call(db, fn(conn) {
    use existing <- result.try(user_repo.get_user(conn, user_id))
    case existing {
      None -> Ok(None)
      Some(_) -> {
        use _ <- result.try(user_repo.update_profile(conn, user_id, input.display_name, input.enabled, now))
        use _ <- result.try(user_repo.set_roles(conn, user_id, input.roles))
        use _ <- result.try(user_repo.set_warehouses(conn, user_id, input.warehouse_ids))
        use _ <- result.try(case is_self {
          True -> Ok(Nil)
          False -> user_repo.delete_user_sessions(conn, user_id, "")
        })
        user_repo.get_user(conn, user_id)
      }
    }
  }))
  case found {
    None -> Error(NotFound("用户不存在"))
    Some(user) -> {
      audit(db, actor, "user.update", user.username, 0, describe_input(input))
      Ok(user)
    }
  }
}

pub fn reset_password(
  db: Database,
  actor: Principal,
  user_id: String,
  secret: String,
) -> Result(Nil, AuthError) {
  use secret <- result.try(password.validate(secret) |> result.map_error(Rejected))
  let hash = password.hash(secret)
  let keep = case user_id == actor.user_id {
    True -> actor.token_hash
    False -> ""
  }
  use found <- result.try(db_call(db, fn(conn) {
    use existing <- result.try(user_repo.get_user(conn, user_id))
    case existing {
      None -> Ok(None)
      Some(user) -> {
        use _ <- result.try(user_repo.set_password(conn, user_id, hash, clock.now_local()))
        use _ <- result.try(user_repo.delete_user_sessions(conn, user_id, keep))
        Ok(Some(user))
      }
    }
  }))
  case found {
    None -> Error(NotFound("用户不存在"))
    Some(user) -> {
      audit(db, actor, "user.reset_password", user.username, 0, "")
      Ok(Nil)
    }
  }
}

fn validate_input(db: Database, input: UserInput) -> Result(UserInput, AuthError) {
  let display_name = string.trim(input.display_name)
  let roles = list.unique(list.map(input.roles, string.trim))
  let warehouse_ids = list.unique(input.warehouse_ids)
  use <- guard(display_name == "", Rejected("姓名不能为空"))
  use <- guard(string.length(display_name) > 32, Rejected("姓名不能超过 32 个字"))
  use <- guard(roles == [], Rejected("至少分配一个角色"))
  use known <- result.try(db_call(db, user_repo.list_roles))
  let codes = list.map(known, fn(role: user_repo.RoleRow) { role.code })
  use <- guard(
    !list.all(roles, fn(role) { list.contains(codes, role) }),
    Rejected("角色不存在"),
  )
  use <- guard(
    !list.contains(roles, rbac.sys_admin) && warehouse_ids == [],
    Rejected("至少授权一个仓库"),
  )
  use found <- result.try(db_call(db, fn(conn) {
    list.try_map(warehouse_ids, fn(id) { repo.get_warehouse(conn, id) })
  }))
  use <- guard(list.any(found, option.is_none), Rejected("授权的仓库不存在"))
  Ok(UserInput(
    display_name: display_name,
    enabled: input.enabled,
    roles: roles,
    warehouse_ids: warehouse_ids,
  ))
}

fn describe_input(input: UserInput) -> String {
  "角色 "
  <> string.join(input.roles, ",")
  <> ";仓库 "
  <> string.join(list.map(input.warehouse_ids, int.to_string), ",")
  <> ";"
  <> case input.enabled {
    True -> "启用"
    False -> "停用"
  }
}

pub fn normalize_username(raw: String) -> Result(String, String) {
  let username = string.lowercase(string.trim(raw))
  let length = string.length(username)
  let valid_chars =
    string.to_graphemes(username)
    |> list.all(fn(c) { string.contains("abcdefghijklmnopqrstuvwxyz0123456789_.-", c) })
  case length >= 2 && length <= 32 && valid_chars {
    True -> Ok(username)
    False -> Error("账号为 2–32 位小写字母、数字或 _ . -")
  }
}

pub fn audit(
  db: Database,
  principal: Principal,
  action: String,
  target: String,
  warehouse_id: Int,
  detail: String,
) -> Nil {
  audit_raw(db, principal.user_id, principal.username, action, target, warehouse_id, detail)
}

fn audit_raw(
  db: Database,
  user_id: String,
  username: String,
  action: String,
  target: String,
  warehouse_id: Int,
  detail: String,
) -> Nil {
  let now = clock.now_local()
  let entry =
    AuditEntry(
      id: now <> "-" <> password.random_hex(4),
      user_id: user_id,
      username: username,
      action: action,
      target: target,
      warehouse_id: warehouse_id,
      detail: detail,
      created_at: now,
    )
  case database.transaction(db, fn(conn) { user_repo.insert_audit(conn, entry) }) {
    Ok(_) -> Nil
    Error(database.DbError(message)) ->
      logging.log(logging.Error, "审计日志写入失败 " <> action <> ": " <> message)
  }
}

fn new_user_id() -> String {
  "U" <> password.random_hex(8)
}

fn db_call(
  db: Database,
  f: fn(database.Conn) -> Result(a, DbError),
) -> Result(a, AuthError) {
  database.transaction(db, f) |> result.map_error(AuthDb)
}

fn guard(condition: Bool, error: AuthError, next: fn() -> Result(a, AuthError)) -> Result(a, AuthError) {
  case condition {
    True -> Error(error)
    False -> next()
  }
}

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/IaC/SwiftFlow

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.