From da51713159a095a1856751e2d62ff4a1ce135ced Mon Sep 17 00:00:00 2001 From: mac Date: Mon, 13 Jul 2026 14:39:56 +0800 Subject: [PATCH] Add authserver module --- authserver/.dockerignore | 9 + authserver/.env.example | 16 + authserver/.gitignore | 12 + authserver/Dockerfile.backend | 26 ++ authserver/README.md | 339 ++++++++++++++++++ authserver/docker-compose.mysql.yml | 19 ++ authserver/go.mod | 48 +++ authserver/go.sum | 102 ++++++ authserver/internal/auth/jwt.go | 114 +++++++ authserver/internal/auth/random.go | 14 + authserver/internal/config/config.go | 143 ++++++++ authserver/internal/database/database.go | 21 ++ authserver/internal/database/seed.go | 11 + authserver/internal/handler/context.go | 39 +++ authserver/internal/handler/health.go | 33 ++ authserver/internal/handler/oauth.go | 290 ++++++++++++++++ authserver/internal/handler/saml.go | 114 +++++++ authserver/internal/handler/user.go | 27 ++ authserver/internal/handler/wayen.go | 206 +++++++++++ authserver/internal/model/models.go | 68 ++++ authserver/internal/router/router.go | 56 +++ authserver/internal/service/audit.go | 60 ++++ authserver/internal/service/auth.go | 245 +++++++++++++ authserver/internal/service/wayen.go | 143 ++++++++ authserver/internal/sso/login.go | 415 +++++++++++++++++++++++ authserver/internal/sso/login_test.go | 190 +++++++++++ authserver/internal/sso/metadata.go | 147 ++++++++ authserver/web/.dockerignore | 4 + authserver/web/Dockerfile | 16 + authserver/web/index.html | 12 + authserver/web/nginx.conf | 53 +++ authserver/web/package-lock.json | 379 +++++++++++++++++++++ authserver/web/package.json | 20 ++ authserver/web/src/App.vue | 211 ++++++++++++ authserver/web/src/main.js | 5 + authserver/web/src/style.css | 230 +++++++++++++ authserver/web/vite.config.js | 15 + 37 files changed, 3852 insertions(+) create mode 100644 authserver/.dockerignore create mode 100644 authserver/.env.example create mode 100644 authserver/.gitignore create mode 100644 authserver/Dockerfile.backend create mode 100644 authserver/README.md create mode 100644 authserver/docker-compose.mysql.yml create mode 100644 authserver/go.mod create mode 100644 authserver/go.sum create mode 100644 authserver/internal/auth/jwt.go create mode 100644 authserver/internal/auth/random.go create mode 100644 authserver/internal/config/config.go create mode 100644 authserver/internal/database/database.go create mode 100644 authserver/internal/database/seed.go create mode 100644 authserver/internal/handler/context.go create mode 100644 authserver/internal/handler/health.go create mode 100644 authserver/internal/handler/oauth.go create mode 100644 authserver/internal/handler/saml.go create mode 100644 authserver/internal/handler/user.go create mode 100644 authserver/internal/handler/wayen.go create mode 100644 authserver/internal/model/models.go create mode 100644 authserver/internal/router/router.go create mode 100644 authserver/internal/service/audit.go create mode 100644 authserver/internal/service/auth.go create mode 100644 authserver/internal/service/wayen.go create mode 100644 authserver/internal/sso/login.go create mode 100644 authserver/internal/sso/login_test.go create mode 100644 authserver/internal/sso/metadata.go create mode 100644 authserver/web/.dockerignore create mode 100644 authserver/web/Dockerfile create mode 100644 authserver/web/index.html create mode 100644 authserver/web/nginx.conf create mode 100644 authserver/web/package-lock.json create mode 100644 authserver/web/package.json create mode 100644 authserver/web/src/App.vue create mode 100644 authserver/web/src/main.js create mode 100644 authserver/web/src/style.css create mode 100644 authserver/web/vite.config.js diff --git a/authserver/.dockerignore b/authserver/.dockerignore new file mode 100644 index 0000000..6383146 --- /dev/null +++ b/authserver/.dockerignore @@ -0,0 +1,9 @@ +.cache/ +.git/ +.DS_Store +.env +bin/ +data/ +web/node_modules/ +web/dist/ + diff --git a/authserver/.env.example b/authserver/.env.example new file mode 100644 index 0000000..827f866 --- /dev/null +++ b/authserver/.env.example @@ -0,0 +1,16 @@ +APP_ENV=dev +HTTP_ADDR=:8080 +PUBLIC_BASE_URL=http://localhost:8080 + +MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local +AUTO_MIGRATE=true + +JWT_SECRET=change-this-secret +JWT_ISSUER=authserver +JWT_TTL_MINUTES=120 + +SAML_ENTITY_ID=http://localhost:8080/api/v1/saml/metadata +SAML_ACS_URL=http://localhost:8080/api/v1/saml/acs +SAML_IDP_METADATA_URL=http://sso-internal.dev.qiniu.io/saml2/meta +SAML_SP_CERT_FILE=certs/sp.crt +SAML_SP_KEY_FILE=certs/sp.key diff --git a/authserver/.gitignore b/authserver/.gitignore new file mode 100644 index 0000000..24fa82a --- /dev/null +++ b/authserver/.gitignore @@ -0,0 +1,12 @@ +.cache/ +.env +.DS_Store +*.tar +bin/ +server +web/node_modules/ +web/dist/ +certs/ +scripts/ +data/ +docs/ diff --git a/authserver/Dockerfile.backend b/authserver/Dockerfile.backend new file mode 100644 index 0000000..527ec8e --- /dev/null +++ b/authserver/Dockerfile.backend @@ -0,0 +1,26 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct \ + GOSUMDB=sum.golang.google.cn + +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/authserver ./cmd/server + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /authserver/backend + +COPY --from=builder /out/authserver /usr/local/bin/authserver + +EXPOSE 8083 + +CMD ["authserver"] diff --git a/authserver/README.md b/authserver/README.md new file mode 100644 index 0000000..5cba5e2 --- /dev/null +++ b/authserver/README.md @@ -0,0 +1,339 @@ +# AuthServer + +最小 Gin + MySQL 权限服务实践。 + +## 当前能力 + +- Gin HTTP 服务 +- MySQL / GORM 连接 +- 核心表 AutoMigrate +- 默认环境和身份源初始化 +- 健康检查 +- 本地登录接口骨架 +- LDAP / 内部 SSO provider 预留 +- 权限判断接口骨架 +- 管理员用户、用户组、角色、权限、授权绑定 API +- 业务线、namespace、cluster 管理 API +- Rancher 同步预览接口 +- 审计日志落库 +- Vue 管理后台 + +## 启动 + +```bash +cp .env.example .env +go mod tidy +go run ./cmd/server +``` + +本地 Docker MySQL 8.0.46: + +```bash +docker compose -f docker-compose.mysql.yml up -d +``` + +这个 MySQL 会映射到本机 `3000` 端口,`.env.example` 默认 DSN 已按这个端口配置: + +```env +MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local +``` + +连接测试: + +```bash +mysql -h127.0.0.1 -P3000 -uauth -pauth authserver +``` + +前端开发服务: + +```bash +cd web +npm install +npm run dev +``` + +默认访问: + +```text +http://127.0.0.1:5173 +``` + +Vite 会把 `/api` 代理到后端 `http://127.0.0.1:8080`。 + +如果 MySQL 还没有创建库和账号,可以用 root 执行: + +```bash +mysql -uroot -p < docs/mysql-init.sql +``` + +如果你使用的是 Docker Desktop、OrbStack、Colima 或容器里的 MySQL,应用访问 MySQL 时来源 IP 可能不是 `localhost`,而是类似 `192.168.65.1`。这时 MySQL 账号需要允许远程来源,`docs/mysql-init.sql` 已经包含 `'auth'@'%'`。 + +如果要自动创建本地管理员账号,在 `.env` 里设置: + +```bash +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD='your-password' +``` + +也可以直接使用环境变量: + +```bash +MYSQL_DSN='auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local' \ +JWT_SECRET='change-this-secret' \ +BOOTSTRAP_ADMIN_PASSWORD='your-password' \ +go run ./cmd/server +``` + +## 发版 + +发布脚本会在本地完成: + +- 构建后端和前端 Docker 镜像 +- 读取本地 `.env`,生成并应用线上 `ConfigMap`/`Secret` +- 导出 `authserver-images-.tar` +- 上传到服务器 +- 导入 RKE2 使用的 `containerd` +- 更新 `authserver` namespace 下的 deployment 并等待 rollout 完成 + +默认目标与你当前线上环境一致: + +- 主机:`root@218.11.5.223` +- 私钥:`/Users/mac/dev/xengineer-cs2.pem` +- 远端目录:`/authserver` +- containerd socket:`/run/k3s/containerd/containerd.sock` + +发布脚本默认把仓库根目录的 `.env` 当作线上配置源。也就是说,发版前需要先把 `.env` 改成目标环境的实际值;脚本会在发版时重新生成并应用 `authserver-config` 和 `authserver-secret`。 + +脚本默认不会拦截测试环境配置。如果你要对正式环境启用更严格的保护,可以显式打开: + +```bash +STRICT_DEPLOY_ENV=true bash scripts/release.sh 1.0.5 +``` + +开启后会拦截几类明显错误: + +- `APP_ENV != prod` +- `MYSQL_DSN` 指向 `127.0.0.1` 或 `localhost` +- `PUBLIC_BASE_URL`、`SAML_ENTITY_ID`、`SAML_ACS_URL`、`WAYEN_LOGIN_URL`、`WAYEN_TARGET_URL` 指向 `127.0.0.1` 或 `localhost` + +执行方式: + +```bash +bash scripts/release.sh 1.0.5 +``` + +如果需要覆盖默认值,可以传环境变量: + +```bash +ENV_FILE=.env \ +DEPLOY_HOST=218.11.5.223 \ +DEPLOY_KEY=/path/to/key.pem \ +REMOTE_DIR=/authserver \ +bash scripts/release.sh 1.0.5 +``` + +如果只是想在服务器上重启已部署的服务,可以执行: + +```bash +bash scripts/restart-authserver.sh +``` + +也支持只重启单个 deployment: + +```bash +bash scripts/restart-authserver.sh backend +bash scripts/restart-authserver.sh nginx +``` + +## 接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/healthz` | 服务健康检查 | +| `GET` | `/readyz` | 数据库连接检查 | +| `POST` | `/api/v1/login` | 本地登录 | +| `POST` | `/api/v1/login/ldap` | LDAP 登录预留 | +| `GET` | `/api/v1/login/:provider` | SSO 跳转预留 | +| `GET` | `/api/v1/login/:provider/callback` | SSO 回调预留 | +| `POST` | `/api/v1/authz/check` | 权限判断 | +| `GET` | `/api/v1/users/me` | 当前用户 | +| `GET` | `/api/v1/wayen/login` | 根据 token 邮箱登录 Wayen 并跳转 | +| `GET` | `/api/v1/saml/metadata` | SAML SP metadata | +| `GET` | `/api/v1/login/internal-sso` | 发起 SAML SSO 登录 | +| `POST` | `/api/v1/saml/acs` | SAML ACS 回调,当前仅调试打印 | +| `GET` | `/auth/oauth/authorize` | OAuth2 Authorization Code 授权入口,供 Wayne 使用 | +| `POST` | `/auth/oauth/token` | OAuth2 code 换 access token | +| `GET` | `/auth/oauth/userinfo` | OAuth2 bearer token 查询当前用户 | + +## SAML Metadata + +服务会根据 `.env` 和 `certs/sp.crt` 动态生成 SP metadata,不需要手工维护 `certs/spmeta`。 +发起 SSO 登录时会实时请求 `.env` 里的 `SAML_IDP_METADATA_URL`,不再读取本地 `certs/idpmeta`。 + +本地后端如果运行在 `8083`,建议配置: + +```env +HTTP_ADDR=:8083 +PUBLIC_BASE_URL=http://localhost:8083 +SAML_ENTITY_ID=http://localhost:8083/api/v1/saml/metadata +SAML_ACS_URL=http://localhost:8083/api/v1/saml/acs +SAML_IDP_METADATA_URL=http://sso-internal.dev.qiniu.io/saml2/meta +SAML_SP_CERT_FILE=certs/sp.crt +SAML_SP_KEY_FILE=certs/sp.key +``` + +给 IdP 配置的 SP metadata 地址: + +```text +http://localhost:8083/api/v1/saml/metadata +``` + +调试 SSO 登录入口: + +```text +http://localhost:8083/api/v1/login/internal-sso +``` + +ACS 会处理 IdP 回调: + +```text +POST /api/v1/saml/acs +``` + +收到 IdP 回调后,会把 SAMLResponse 解码;如果 Assertion 被加密,会用 `SAML_SP_KEY_FILE` 对应私钥解密,并在后端控制台打印解密后的摘要和 Assertion: + +- Response ID +- InResponseTo +- Issuer +- Destination +- NameID +- Attributes +- 解密后的 Assertion XML + +当前 SAML 用户落库规则: + +- `eduPersonPrincipalName` 同时作为 `users.username` 和 `users.email`。 +- `NameID` 写入 `users.external_id`。 +- 首次登录会自动创建 `source=saml` 的用户。 +- 不再创建 `identity_providers` / `user_identities` 表记录。 +- 登录成功后签发 JWT,写入 `authserver_token` HttpOnly cookie,并带着 token 跳回前端。 + +当前阶段还没有做: + +- SAML 签名验证 + +## Wayne OAuth2 对接 + +方案 1 下 AuthServer 作为 OAuth2 Provider,Wayne 作为 OAuth2 Client。Wayne 登录时不再校验 AuthServer 里的 Wayne 密码,也不需要 AuthServer 存储 Wayne 资源组信息;AuthServer 只负责 SAML 登录、签发 token、返回 `name/email/display`,Wayne 收到 userinfo 后会在 Wayne 自己数据库里创建或更新本地用户,Wayne 的资源组和权限仍由 Wayne 自己维护。 + +AuthServer 端配置: + +```env +OAUTH_WAYNE_CLIENT_ID=wayne +OAUTH_WAYNE_CLIENT_SECRET=change-this-wayne-client-secret +OAUTH_WAYNE_REDIRECT_URI=http://127.0.0.1:8080/login/oauth2/oauth2 +OAUTH_CODE_TTL_SECONDS=120 +``` + +AuthServer 暴露给 Wayne 的 OAuth2 地址: + +```text +GET /auth/oauth/authorize +POST /auth/oauth/token +GET /auth/oauth/userinfo +``` + +Wayne `app.conf` 示例: + +```ini +[auth.oauth2] +enabled = true +redirect_url = http://127.0.0.1:8080 +client_id = wayne +client_secret = change-this-wayne-client-secret +auth_url = http://218.11.5.223/auth/oauth/authorize +token_url = http://218.11.5.223/auth/oauth/token +api_url = http://218.11.5.223/auth/oauth/userinfo +api_mapping = name:name,email:email,display:display +scopes = profile,email +``` + +Wayne 会把回调地址拼成: + +```text +{redirect_url}/login/oauth2/oauth2 +``` + +因此 `OAUTH_WAYNE_REDIRECT_URI` 必须和 Wayne 实际回调地址完全一致。浏览器访问 Wayne OAuth 登录入口后,如果 AuthServer 还没有登录态,会先跳内部 SAML;SAML 成功后再回到 OAuth authorize,签发 code 给 Wayne。 + +管理员可查看当前 SAML metadata 配置: + +```text +GET /api/v1/admin/saml/metadata/config +``` + +## 管理员接口 + +管理员接口需要 `Authorization: Bearer `,并且 token 里的用户必须是 `is_admin=true`。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/api/v1/admin/users` | 用户列表 | +| `POST` | `/api/v1/admin/users` | 创建用户,可带本地密码 | +| `PUT` | `/api/v1/admin/users/:id` | 更新用户 | +| `DELETE` | `/api/v1/admin/users/:id` | 删除用户 | +| `GET` | `/api/v1/admin/groups` | 用户组列表 | +| `POST` | `/api/v1/admin/groups` | 创建用户组 | +| `POST` | `/api/v1/admin/groups/:id/members` | 用户加入组 | +| `DELETE` | `/api/v1/admin/groups/:id/members/:userID` | 用户移出组 | +| `GET` | `/api/v1/admin/roles` | 角色列表 | +| `POST` | `/api/v1/admin/roles` | 创建角色 | +| `PUT` | `/api/v1/admin/roles/:id/permissions` | 设置角色权限 | +| `GET` | `/api/v1/admin/permissions` | 权限点列表 | +| `POST` | `/api/v1/admin/permissions` | 创建权限点 | +| `GET` | `/api/v1/admin/business-lines` | 业务线列表 | +| `POST` | `/api/v1/admin/business-lines` | 创建业务线 | +| `GET` | `/api/v1/admin/namespaces` | namespace 列表 | +| `POST` | `/api/v1/admin/namespaces` | 创建 namespace | +| `GET` | `/api/v1/admin/environments` | 环境列表 | +| `GET` | `/api/v1/admin/clusters` | 集群列表 | +| `POST` | `/api/v1/admin/clusters` | 创建集群 | +| `GET` | `/api/v1/admin/wayen/credentials` | Wayen 凭据列表,不返回密码 | +| `POST` | `/api/v1/admin/wayen/credentials` | 按邮箱保存 Wayen 密码 | +| `DELETE` | `/api/v1/admin/wayen/credentials/:id` | 删除 Wayen 凭据 | +| `GET` | `/api/v1/admin/role-bindings` | 授权绑定列表 | +| `POST` | `/api/v1/admin/role-bindings` | 创建授权绑定 | +| `DELETE` | `/api/v1/admin/role-bindings/:id` | 删除授权绑定 | +| `POST` | `/api/v1/admin/rancher/sync` | Rancher 同步预览 | +| `GET` | `/api/v1/admin/rancher/sync-status` | Rancher 同步状态 | +| `GET` | `/api/v1/admin/saml/metadata/config` | SAML metadata 配置 | + +## Rancher 对齐流程 + +当前 Rancher 同步接口是 dry-run,不会真实调用 Rancher API。推荐先按下面流程把本系统数据配齐: + +1. 创建环境和集群:`rke2-dev`、`rke2-test`、`rke2-prod`。 +2. 创建业务线:例如 `pay`。 +3. 创建 namespace:例如 `pay`,归属支付业务线。 +4. 创建权限点:例如 `deployment:deploy`、`deployment:rollback`。 +5. 创建角色:例如 `prod-deployer`。 +6. 给角色绑定权限点。 +7. 创建用户组:例如 `pay-prod-deployer`。 +8. 把 SSO/LDAP 用户加入用户组。 +9. 创建授权绑定:`group + role + cluster + namespace`。 +10. 调用 `/api/v1/admin/rancher/sync` 查看将要同步到 Rancher 的映射。 + +核心授权绑定应该长这样: + +```json +{ + "subject_type": "group", + "subject_id": 1, + "role_id": 1, + "scope_type": "cluster_namespace", + "cluster_id": 3, + "namespace_id": 1 +} +``` + +这表示某个用户组在 prod 集群的 `pay` namespace 下拥有指定角色。 diff --git a/authserver/docker-compose.mysql.yml b/authserver/docker-compose.mysql.yml new file mode 100644 index 0000000..ff14f67 --- /dev/null +++ b/authserver/docker-compose.mysql.yml @@ -0,0 +1,19 @@ +services: + mysql: + image: mysql:8.0.46 + container_name: authserver-mysql + restart: unless-stopped + ports: + - "3000:3306" + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: authserver + MYSQL_USER: auth + MYSQL_PASSWORD: auth + TZ: Asia/Shanghai + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-authentication-plugin=mysql_native_password + volumes: + - ./data/mysql:/var/lib/mysql diff --git a/authserver/go.mod b/authserver/go.mod new file mode 100644 index 0000000..16af137 --- /dev/null +++ b/authserver/go.mod @@ -0,0 +1,48 @@ +module authserver + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + golang.org/x/crypto v0.40.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.30.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/authserver/go.sum b/authserver/go.sum new file mode 100644 index 0000000..d4f772e --- /dev/null +++ b/authserver/go.sum @@ -0,0 +1,102 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= +gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/authserver/internal/auth/jwt.go b/authserver/internal/auth/jwt.go new file mode 100644 index 0000000..ef9e786 --- /dev/null +++ b/authserver/internal/auth/jwt.go @@ -0,0 +1,114 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID uint64 `json:"uid"` + Username string `json:"username"` + Email string `json:"email"` + IsAdmin bool `json:"admin"` + jwt.RegisteredClaims +} + +type OAuthCodeClaims struct { + UserID uint64 `json:"uid"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + jwt.RegisteredClaims +} + +func Sign(secret, issuer string, ttl time.Duration, userID uint64, username, email string, isAdmin bool) (string, string, time.Time, error) { + now := time.Now() + expiresAt := now.Add(ttl) + tokenID := randomID() + claims := Claims{ + UserID: userID, + Username: username, + Email: email, + IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ID: tokenID, + Issuer: issuer, + Subject: username, + Audience: []string{"authserver"}, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secret)) + return signed, tokenID, expiresAt, err +} + +func SignOAuthCode(secret, issuer string, ttl time.Duration, userID uint64, clientID, redirectURI string) (string, string, time.Time, error) { + now := time.Now() + expiresAt := now.Add(ttl) + codeID := randomID() + claims := OAuthCodeClaims{ + UserID: userID, + ClientID: clientID, + RedirectURI: redirectURI, + RegisteredClaims: jwt.RegisteredClaims{ + ID: codeID, + Issuer: issuer, + Subject: clientID, + Audience: []string{"oauth-code"}, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secret)) + return signed, codeID, expiresAt, err +} + +func Parse(secret string, tokenValue string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected jwt signing method") + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("invalid jwt claims") + } + return claims, nil +} + +func ParseOAuthCode(secret string, tokenValue string) (*OAuthCodeClaims, error) { + token, err := jwt.ParseWithClaims(tokenValue, &OAuthCodeClaims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected jwt signing method") + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*OAuthCodeClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid oauth code claims") + } + if !claimStringsContains(claims.Audience, "oauth-code") { + return nil, errors.New("invalid oauth code audience") + } + return claims, nil +} + +func claimStringsContains(values jwt.ClaimStrings, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/authserver/internal/auth/random.go b/authserver/internal/auth/random.go new file mode 100644 index 0000000..caeaa8d --- /dev/null +++ b/authserver/internal/auth/random.go @@ -0,0 +1,14 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" +) + +func randomID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "" + } + return hex.EncodeToString(b[:]) +} diff --git a/authserver/internal/config/config.go b/authserver/internal/config/config.go new file mode 100644 index 0000000..d5a63fa --- /dev/null +++ b/authserver/internal/config/config.go @@ -0,0 +1,143 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + AppEnv string + HTTPAddr string + PublicBaseURL string + MySQLDSN string + AutoMigrate bool + JWTSecret string + JWTIssuer string + JWTTTLMinutes int + SAMLEntityID string + SAMLACSURL string + SAMLSPCert string + SAMLSPKey string + SAMLIDPMetaURL string + WayenLoginURL string + WayenTargetURL string + WayenUsernameKey string + WayenPasswordKey string + WayenLoginFormat string + WayenLoginValue string + OAuthClientID string + OAuthClientSecret string + OAuthRedirectURI string + OAuthCodeTTLSeconds int +} + +func Load() Config { + loadDotEnv(".env") + httpAddr := env("HTTP_ADDR", ":8080") + publicBaseURL := env("PUBLIC_BASE_URL", defaultPublicBaseURL(httpAddr)) + samlEntityID := env("SAML_ENTITY_ID", strings.TrimRight(publicBaseURL, "/")+"/auth/api/v1/saml/metadata") + samlACSURL := env("SAML_ACS_URL", strings.TrimRight(publicBaseURL, "/")+"/auth/api/v1/saml/acs") + + return Config{ + AppEnv: env("APP_ENV", "dev"), + HTTPAddr: httpAddr, + PublicBaseURL: publicBaseURL, + MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"), + AutoMigrate: envBool("AUTO_MIGRATE", true), + JWTSecret: env("JWT_SECRET", "change-this-secret"), + JWTIssuer: env("JWT_ISSUER", "authserver"), + JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120), + SAMLEntityID: samlEntityID, + SAMLACSURL: samlACSURL, + SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"), + SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"), + SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"), + WayenLoginURL: env("WAYEN_LOGIN_URL", ""), + WayenTargetURL: env("WAYEN_TARGET_URL", ""), + WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"), + WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"), + WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"), + WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"), + OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"), + OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"), + OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""), + OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120), + } +} + +func loadDotEnv(path string) { + content, err := os.ReadFile(path) + if err != nil { + return + } + lines := strings.Split(string(content), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.Trim(strings.TrimSpace(value), `"'`) + if key == "" || os.Getenv(key) != "" { + continue + } + _ = os.Setenv(key, value) + } +} + +func (c Config) JWTTTL() time.Duration { + return time.Duration(c.JWTTTLMinutes) * time.Minute +} + +func (c Config) OAuthCodeTTL() time.Duration { + return time.Duration(c.OAuthCodeTTLSeconds) * time.Second +} + +func env(key, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} + +func envBool(key string, fallback bool) bool { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} + +func envInt(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func defaultPublicBaseURL(httpAddr string) string { + addr := strings.TrimSpace(httpAddr) + if addr == "" { + return "http://localhost:8080" + } + if strings.HasPrefix(addr, ":") { + return "http://localhost" + addr + } + return "http://" + addr +} diff --git a/authserver/internal/database/database.go b/authserver/internal/database/database.go new file mode 100644 index 0000000..ca19b6e --- /dev/null +++ b/authserver/internal/database/database.go @@ -0,0 +1,21 @@ +package database + +import ( + "authserver/internal/model" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func Open(dsn string) (*gorm.DB, error) { + return gorm.Open(mysql.Open(dsn), &gorm.Config{}) +} + +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &model.User{}, + &model.WayenCredential{}, + &model.AccessToken{}, + &model.AuditLog{}, + ) +} diff --git a/authserver/internal/database/seed.go b/authserver/internal/database/seed.go new file mode 100644 index 0000000..2d06763 --- /dev/null +++ b/authserver/internal/database/seed.go @@ -0,0 +1,11 @@ +package database + +import ( + "authserver/internal/config" + + "gorm.io/gorm" +) + +func SeedDefaults(_ *gorm.DB, _ config.Config) error { + return nil +} diff --git a/authserver/internal/handler/context.go b/authserver/internal/handler/context.go new file mode 100644 index 0000000..dbb1acd --- /dev/null +++ b/authserver/internal/handler/context.go @@ -0,0 +1,39 @@ +package handler + +import ( + "net/http" + "strings" + + "authserver/internal/auth" + "authserver/internal/config" + + "github.com/gin-gonic/gin" +) + +const ClaimsKey = "claims" + +func AuthMiddleware(cfg config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + value := c.GetHeader("Authorization") + if !strings.HasPrefix(value, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) + return + } + claims, err := auth.Parse(cfg.JWTSecret, strings.TrimPrefix(value, "Bearer ")) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + c.Set(ClaimsKey, claims) + c.Next() + } +} + +func CurrentClaims(c *gin.Context) (*auth.Claims, bool) { + value, ok := c.Get(ClaimsKey) + if !ok { + return nil, false + } + claims, ok := value.(*auth.Claims) + return claims, ok +} diff --git a/authserver/internal/handler/health.go b/authserver/internal/handler/health.go new file mode 100644 index 0000000..91412b0 --- /dev/null +++ b/authserver/internal/handler/health.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type HealthHandler struct { + db *gorm.DB +} + +func NewHealthHandler(db *gorm.DB) *HealthHandler { + return &HealthHandler{db: db} +} + +func (h *HealthHandler) Healthz(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *HealthHandler) Readyz(c *gin.Context) { + sqlDB, err := h.db.DB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()}) + return + } + if err := sqlDB.PingContext(c.Request.Context()); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/authserver/internal/handler/oauth.go b/authserver/internal/handler/oauth.go new file mode 100644 index 0000000..db61b34 --- /dev/null +++ b/authserver/internal/handler/oauth.go @@ -0,0 +1,290 @@ +package handler + +import ( + "encoding/base64" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "authserver/internal/auth" + "authserver/internal/config" + "authserver/internal/model" + "authserver/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const AuthSessionCookieName = "authserver_token" + +type OAuthHandler struct { + cfg config.Config + db *gorm.DB + audit *service.AuditService +} + +func NewOAuthHandler(cfg config.Config, db *gorm.DB, audit *service.AuditService) *OAuthHandler { + return &OAuthHandler{cfg: cfg, db: db, audit: audit} +} + +func (h *OAuthHandler) Authorize(c *gin.Context) { + if !h.oauthConfigured() { + c.JSON(http.StatusInternalServerError, gin.H{"error": "oauth client is not configured"}) + return + } + + clientID := strings.TrimSpace(c.Query("client_id")) + redirectURI := strings.TrimSpace(c.Query("redirect_uri")) + responseType := strings.TrimSpace(c.Query("response_type")) + state := c.Query("state") + + if responseType != "code" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported response_type"}) + return + } + if !h.validClientRedirect(clientID, redirectURI) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid client_id or redirect_uri"}) + return + } + + user, ok := h.sessionUser(c) + if !ok { + loginURL := "/auth/api/v1/login/internal-sso?relay_state=" + url.QueryEscape(c.Request.URL.RequestURI()) + c.Redirect(http.StatusFound, loginURL) + return + } + + code, codeID, expiresAt, err := auth.SignOAuthCode(h.cfg.JWTSecret, h.cfg.JWTIssuer, h.cfg.OAuthCodeTTL(), user.ID, clientID, redirectURI) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := h.db.Create(&model.AccessToken{ + UserID: user.ID, + TokenID: codeID, + TokenType: "oauth_code", + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + ExpiresAt: expiresAt, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + h.audit.Write(service.AuditEntry{ + ActorUserID: user.ID, + ActorUsername: user.Username, + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "oauth.authorize", + ResourceType: "oauth_client", + ResourceID: clientID, + Decision: "allow", + }) + + target, err := url.Parse(redirectURI) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid redirect_uri"}) + return + } + values := target.Query() + values.Set("code", code) + if state != "" { + values.Set("state", state) + } + target.RawQuery = values.Encode() + c.Redirect(http.StatusFound, target.String()) +} + +func (h *OAuthHandler) Token(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + + if !h.oauthConfigured() { + c.JSON(http.StatusInternalServerError, gin.H{"error": "oauth client is not configured"}) + return + } + if err := c.Request.ParseForm(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + clientID, clientSecret := oauthClientCredentials(c) + redirectURI := strings.TrimSpace(c.PostForm("redirect_uri")) + code := strings.TrimSpace(c.PostForm("code")) + if c.PostForm("grant_type") != "authorization_code" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported grant_type"}) + return + } + if clientID != h.cfg.OAuthClientID || clientSecret != h.cfg.OAuthClientSecret { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid client credentials"}) + return + } + + codeClaims, err := auth.ParseOAuthCode(h.cfg.JWTSecret, code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid code"}) + return + } + if codeClaims.ClientID != clientID || codeClaims.RedirectURI != redirectURI || !h.validClientRedirect(clientID, redirectURI) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid code"}) + return + } + + var token string + var expiresAt time.Time + var user model.User + now := time.Now() + err = h.db.Transaction(func(tx *gorm.DB) error { + var codeRecord model.AccessToken + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("token_id = ? AND token_type = ? AND revoked = ? AND expires_at > ?", codeClaims.ID, "oauth_code", false, now). + First(&codeRecord).Error; err != nil { + return err + } + if err := tx.First(&user, "id = ? AND deleted_at IS NULL", codeClaims.UserID).Error; err != nil { + return err + } + if user.Status != "active" { + return service.ErrUserDisabled + } + + tokenID := "" + var err error + token, tokenID, expiresAt, err = auth.Sign(h.cfg.JWTSecret, h.cfg.JWTIssuer, h.cfg.JWTTTL(), user.ID, user.Username, user.Email, user.IsAdmin) + if err != nil { + return err + } + if err := tx.Model(&codeRecord).Updates(map[string]any{ + "revoked": true, + "revoked_at": &now, + }).Error; err != nil { + return err + } + return tx.Create(&model.AccessToken{ + UserID: user.ID, + TokenID: tokenID, + TokenType: "oauth_access", + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + ExpiresAt: expiresAt, + }).Error + }) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, service.ErrUserDisabled) { + status = http.StatusUnauthorized + } + c.JSON(status, gin.H{"error": "invalid code"}) + return + } + + h.audit.Write(service.AuditEntry{ + ActorUserID: user.ID, + ActorUsername: user.Username, + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "oauth.token", + ResourceType: "oauth_client", + ResourceID: clientID, + Decision: "allow", + }) + + c.JSON(http.StatusOK, gin.H{ + "access_token": token, + "token_type": "Bearer", + "expires_in": int(time.Until(expiresAt).Seconds()), + }) +} + +func (h *OAuthHandler) UserInfo(c *gin.Context) { + claims, err := bearerClaims(h.cfg, c.GetHeader("Authorization")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + var tokenRecord model.AccessToken + if err := h.db.Where("token_id = ? AND revoked = ? AND expires_at > ?", claims.ID, false, time.Now()).First(&tokenRecord).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + var user model.User + if err := h.db.First(&user, "id = ? AND deleted_at IS NULL", claims.UserID).Error; err != nil || user.Status != "active" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + display := strings.TrimSpace(user.DisplayName) + if display == "" { + display = user.Username + } + c.JSON(http.StatusOK, gin.H{ + "name": user.Username, + "email": user.Email, + "display": display, + }) +} + +func (h *OAuthHandler) oauthConfigured() bool { + return h.cfg.OAuthClientID != "" && h.cfg.OAuthClientSecret != "" +} + +func (h *OAuthHandler) validClientRedirect(clientID, redirectURI string) bool { + if clientID == "" || clientID != h.cfg.OAuthClientID || redirectURI == "" { + return false + } + if h.cfg.OAuthRedirectURI != "" { + return redirectURI == h.cfg.OAuthRedirectURI + } + parsed, err := url.Parse(redirectURI) + return err == nil && parsed.IsAbs() && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +func (h *OAuthHandler) sessionUser(c *gin.Context) (model.User, bool) { + tokenValue := "" + if cookie, err := c.Cookie(AuthSessionCookieName); err == nil { + tokenValue = cookie + } + if tokenValue == "" { + tokenValue = c.Query("sso_token") + } + claims, err := auth.Parse(h.cfg.JWTSecret, tokenValue) + if err != nil { + return model.User{}, false + } + + var user model.User + if err := h.db.First(&user, "id = ? AND deleted_at IS NULL", claims.UserID).Error; err != nil || user.Status != "active" { + return model.User{}, false + } + return user, true +} + +func oauthClientCredentials(c *gin.Context) (string, string) { + if id, secret, ok := c.Request.BasicAuth(); ok { + return id, secret + } + value := c.GetHeader("Authorization") + if strings.HasPrefix(value, "Basic ") { + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, "Basic ")) + if err == nil { + id, secret, ok := strings.Cut(string(raw), ":") + if ok { + return id, secret + } + } + } + return strings.TrimSpace(c.PostForm("client_id")), strings.TrimSpace(c.PostForm("client_secret")) +} + +func bearerClaims(cfg config.Config, value string) (*auth.Claims, error) { + if !strings.HasPrefix(value, "Bearer ") { + return nil, errors.New("missing bearer token") + } + return auth.Parse(cfg.JWTSecret, strings.TrimSpace(strings.TrimPrefix(value, "Bearer "))) +} diff --git a/authserver/internal/handler/saml.go b/authserver/internal/handler/saml.go new file mode 100644 index 0000000..a90818d --- /dev/null +++ b/authserver/internal/handler/saml.go @@ -0,0 +1,114 @@ +package handler + +import ( + "log" + "net/http" + "net/url" + "strings" + "time" + + "authserver/internal/config" + "authserver/internal/service" + "authserver/internal/sso" + + "github.com/gin-gonic/gin" +) + +type SAMLHandler struct { + cfg config.Config + auth *service.AuthService +} + +func NewSAMLHandler(cfg config.Config, authService *service.AuthService) *SAMLHandler { + return &SAMLHandler{cfg: cfg, auth: authService} +} + +func (h *SAMLHandler) Metadata(c *gin.Context) { + data, err := sso.BuildSPMetadata(sso.MetadataConfig{ + EntityID: h.cfg.SAMLEntityID, + ACSURL: h.cfg.SAMLACSURL, + CertFile: h.cfg.SAMLSPCert, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Data(http.StatusOK, "application/samlmetadata+xml; charset=utf-8", data) +} + +func (h *SAMLHandler) MetadataConfig(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "entity_id": h.cfg.SAMLEntityID, + "acs_url": h.cfg.SAMLACSURL, + "sp_cert_file": h.cfg.SAMLSPCert, + "sp_key_file": h.cfg.SAMLSPKey, + "idp_metadata_url": h.cfg.SAMLIDPMetaURL, + "sp_metadata_url": h.cfg.SAMLEntityID, + "metadata_generated": true, + }) +} + +func (h *SAMLHandler) Login(c *gin.Context) { + redirectURL, err := sso.BuildLoginRedirect(sso.LoginConfig{ + EntityID: h.cfg.SAMLEntityID, + ACSURL: h.cfg.SAMLACSURL, + IDPMetadataURL: h.cfg.SAMLIDPMetaURL, + RelayState: c.Query("relay_state"), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Redirect(http.StatusFound, redirectURL) +} + +func (h *SAMLHandler) ACS(c *gin.Context) { + if err := c.Request.ParseForm(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + info, err := sso.DecodeSAMLResponse(c.PostForm("SAMLResponse"), h.cfg.SAMLSPKey) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("SAML ACS debug summary:\n%s", info.JSON()) + if info.DecryptedAssertionXML != "" { + log.Printf("SAML ACS decrypted assertion:\n%s", info.DecryptedAssertionXML) + } + + result, err := h.auth.SAMLLogin(info, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + http.SetCookie(c.Writer, &http.Cookie{ + Name: AuthSessionCookieName, + Value: result.Token, + Path: "/auth/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(time.Until(result.ExpiresAt).Seconds()), + }) + c.Redirect(http.StatusFound, ssoRedirectURL(c.PostForm("RelayState"), result.Token)) +} + +func ssoRedirectURL(relayState, token string) string { + target := strings.TrimSpace(relayState) + if target == "" || !strings.HasPrefix(target, "/auth/") { + target = "/auth/" + } + if strings.HasPrefix(target, "//") { + target = "/auth/" + } + parsed, err := url.Parse(target) + if err != nil || parsed.IsAbs() { + parsed = &url.URL{Path: "/auth/"} + } + values := parsed.Query() + values.Set("sso_token", token) + parsed.RawQuery = values.Encode() + return parsed.String() +} diff --git a/authserver/internal/handler/user.go b/authserver/internal/handler/user.go new file mode 100644 index 0000000..07e5056 --- /dev/null +++ b/authserver/internal/handler/user.go @@ -0,0 +1,27 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type UserHandler struct{} + +func NewUserHandler() *UserHandler { + return &UserHandler{} +} + +func (h *UserHandler) Me(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": claims.UserID, + "username": claims.Username, + "email": claims.Email, + "is_admin": claims.IsAdmin, + }) +} diff --git a/authserver/internal/handler/wayen.go b/authserver/internal/handler/wayen.go new file mode 100644 index 0000000..5f2718f --- /dev/null +++ b/authserver/internal/handler/wayen.go @@ -0,0 +1,206 @@ +package handler + +import ( + "errors" + "net/http" + "strings" + + "authserver/internal/auth" + "authserver/internal/model" + "authserver/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type selfWayenCredentialPayload struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type WayenHandler struct { + db *gorm.DB + wayen *service.WayenService + audit *service.AuditService +} + +func NewWayenHandler(db *gorm.DB, wayen *service.WayenService, audit *service.AuditService) *WayenHandler { + return &WayenHandler{db: db, wayen: wayen, audit: audit} +} + +func (h *WayenHandler) Login(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + + email, _, ok := h.currentUser(c, claims) + if !ok { + return + } + + result, err := h.wayen.Login(email, claims.Username) + if err != nil { + h.writeAudit(c, claims.UserID, claims.Username, "deny", err.Error()) + switch { + case errors.Is(err, service.ErrWayenEmailMissing): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrWayenNotConfigured): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrWayenCredentialNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrWayenLoginFailed): + status := http.StatusBadGateway + if strings.Contains(err.Error(), "status 400") { + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + + for _, cookie := range result.Cookies { + http.SetCookie(c.Writer, cookie) + } + h.writeAudit(c, claims.UserID, claims.Username, "allow", "") + if strings.Contains(c.GetHeader("Accept"), "application/json") { + c.JSON(http.StatusOK, gin.H{"target_url": result.TargetURL}) + return + } + c.Redirect(http.StatusFound, result.TargetURL) +} + +func (h *WayenHandler) GetCredential(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + + email, _, ok := h.currentUser(c, claims) + if !ok { + return + } + + response := gin.H{ + "email": email, + "configured": false, + "enabled": false, + } + if email == "" { + c.JSON(http.StatusOK, response) + return + } + + var item model.WayenCredential + if err := h.db.Where("email = ?", email).First(&item).Error; err == nil { + response["configured"] = true + response["enabled"] = item.Enabled + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) +} + +func (h *WayenHandler) SaveCredential(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + + var req selfWayenCredentialPayload + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + email, user, ok := h.currentUser(c, claims) + if !ok { + return + } + if trimmed := strings.TrimSpace(req.Email); trimmed != "" { + email = trimmed + } + if email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"}) + return + } + password := strings.TrimSpace(req.Password) + if password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "password is required"}) + return + } + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if user.ID != 0 && strings.TrimSpace(user.Email) != email { + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("email", email).Error; err != nil { + return err + } + } + + var item model.WayenCredential + err := tx.Where("email = ?", email).First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(&model.WayenCredential{ + Email: email, + Password: password, + Enabled: true, + }).Error + } + if err != nil { + return err + } + return tx.Model(&item).Updates(map[string]any{ + "password": password, + "enabled": true, + }).Error + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + h.audit.Write(service.AuditEntry{ + ActorUserID: claims.UserID, + ActorUsername: claims.Username, + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "wayen.credential.save", + ResourceType: "wayen_credential", + ResourceID: email, + Decision: "allow", + }) + c.JSON(http.StatusOK, gin.H{"email": email, "configured": true, "enabled": true}) +} + +func (h *WayenHandler) currentUser(c *gin.Context, claims *auth.Claims) (string, model.User, bool) { + var user model.User + email := strings.TrimSpace(claims.Email) + if claims.UserID != 0 { + if err := h.db.Select("id", "email").Where("id = ?", claims.UserID).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "current user not found"}) + return "", user, false + } + if email == "" { + email = strings.TrimSpace(user.Email) + } + } + return email, user, true +} + +func (h *WayenHandler) writeAudit(c *gin.Context, userID uint64, username, decision, reason string) { + h.audit.Write(service.AuditEntry{ + ActorUserID: userID, + ActorUsername: username, + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "wayen.login", + ResourceType: "wayen", + Decision: decision, + Reason: reason, + }) +} diff --git a/authserver/internal/model/models.go b/authserver/internal/model/models.go new file mode 100644 index 0000000..c6a052f --- /dev/null +++ b/authserver/internal/model/models.go @@ -0,0 +1,68 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:128;not null;uniqueIndex" json:"username"` + DisplayName string `gorm:"size:128;not null;default:''" json:"display_name"` + Email string `gorm:"size:255;not null;default:'';index" json:"email"` + Phone string `gorm:"size:32;not null;default:''" json:"phone"` + Source string `gorm:"size:32;not null;default:'local';index:idx_users_external,priority:1" json:"source"` + ExternalID string `gorm:"size:128;not null;default:'';index:idx_users_external,priority:2" json:"external_id"` + Status string `gorm:"size:32;not null;default:'active';index" json:"status"` + IsAdmin bool `gorm:"not null;default:false" json:"is_admin"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +type WayenCredential struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Email string `gorm:"size:255;not null;uniqueIndex" json:"email"` + Password string `gorm:"size:512;not null" json:"-"` + Enabled bool `gorm:"not null;default:true;index" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +type AccessToken struct { + ID uint64 `gorm:"primaryKey" json:"id"` + UserID uint64 `gorm:"not null;index" json:"user_id"` + TokenID string `gorm:"size:128;not null;uniqueIndex" json:"token_id"` + TokenType string `gorm:"size:32;not null;default:'access'" json:"token_type"` + ClientIP string `gorm:"size:64;not null;default:''" json:"client_ip"` + UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"` + Revoked bool `gorm:"not null;default:false;index" json:"revoked"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at"` +} + +type AuditLog struct { + ID uint64 `gorm:"primaryKey" json:"id"` + RequestID string `gorm:"size:128;not null;default:''" json:"request_id"` + ActorUserID uint64 `gorm:"not null;default:0;index:idx_actor_time,priority:1" json:"actor_user_id"` + ActorUsername string `gorm:"size:128;not null;default:''" json:"actor_username"` + ClientIP string `gorm:"size:64;not null;default:''" json:"client_ip"` + UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"` + Action string `gorm:"size:128;not null;index:idx_action_time,priority:1" json:"action"` + ResourceType string `gorm:"size:64;not null;default:'';index:idx_resource,priority:1" json:"resource_type"` + ResourceID string `gorm:"size:128;not null;default:'';index:idx_resource,priority:2" json:"resource_id"` + ScopeType string `gorm:"size:32;not null;default:'';index:idx_scope,priority:1" json:"scope_type"` + ScopeID uint64 `gorm:"not null;default:0;index:idx_scope,priority:2" json:"scope_id"` + BusinessLineID uint64 `gorm:"not null;default:0" json:"business_line_id"` + NamespaceID uint64 `gorm:"not null;default:0;index:idx_ns_cluster_env_time,priority:1" json:"namespace_id"` + ClusterID uint64 `gorm:"not null;default:0;index:idx_ns_cluster_env_time,priority:2" json:"cluster_id"` + EnvironmentID uint64 `gorm:"not null;default:0;index:idx_ns_cluster_env_time,priority:3" json:"environment_id"` + Decision string `gorm:"size:32;not null;default:'';index:idx_decision_time,priority:1" json:"decision"` + Reason string `gorm:"size:512;not null;default:''" json:"reason"` + Metadata string `gorm:"type:longtext" json:"metadata"` + CreatedAt time.Time `gorm:"index:idx_actor_time,priority:2;index:idx_action_time,priority:2;index:idx_ns_cluster_env_time,priority:4;index:idx_decision_time,priority:2" json:"created_at"` +} diff --git a/authserver/internal/router/router.go b/authserver/internal/router/router.go new file mode 100644 index 0000000..01556c4 --- /dev/null +++ b/authserver/internal/router/router.go @@ -0,0 +1,56 @@ +package router + +import ( + "authserver/internal/config" + "authserver/internal/handler" + "authserver/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Dependencies struct { + Config config.Config + DB *gorm.DB +} + +func New(deps Dependencies) *gin.Engine { + if deps.Config.AppEnv == "prod" { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + + auditService := service.NewAuditService(deps.DB) + authService := service.NewAuthService(deps.Config, deps.DB, auditService) + wayenService := service.NewWayenService(deps.Config, deps.DB) + + healthHandler := handler.NewHealthHandler(deps.DB) + userHandler := handler.NewUserHandler() + wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService) + samlHandler := handler.NewSAMLHandler(deps.Config, authService) + oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService) + + r.GET("/healthz", healthHandler.Healthz) + r.GET("/readyz", healthHandler.Readyz) + r.GET("/auth/oauth/authorize", oauthHandler.Authorize) + r.POST("/auth/oauth/token", oauthHandler.Token) + r.GET("/auth/oauth/userinfo", oauthHandler.UserInfo) + + v1 := r.Group("/auth/api/v1") + { + v1.GET("/login/internal-sso", samlHandler.Login) + v1.GET("/saml/metadata", samlHandler.Metadata) + v1.POST("/saml/acs", samlHandler.ACS) + + protected := v1.Group("") + protected.Use(handler.AuthMiddleware(deps.Config)) + protected.GET("/users/me", userHandler.Me) + protected.GET("/wayen/login", wayenHandler.Login) + protected.GET("/wayen/credential", wayenHandler.GetCredential) + protected.PUT("/wayen/credential", wayenHandler.SaveCredential) + } + + return r +} diff --git a/authserver/internal/service/audit.go b/authserver/internal/service/audit.go new file mode 100644 index 0000000..60fde17 --- /dev/null +++ b/authserver/internal/service/audit.go @@ -0,0 +1,60 @@ +package service + +import ( + "encoding/json" + + "authserver/internal/model" + + "gorm.io/gorm" +) + +type AuditService struct { + db *gorm.DB +} + +func NewAuditService(db *gorm.DB) *AuditService { + return &AuditService{db: db} +} + +type AuditEntry struct { + RequestID string + ActorUserID uint64 + ActorUsername string + ClientIP string + UserAgent string + Action string + ResourceType string + ResourceID string + ScopeType string + ScopeID uint64 + BusinessLineID uint64 + NamespaceID uint64 + ClusterID uint64 + EnvironmentID uint64 + Decision string + Reason string + Metadata map[string]any +} + +func (s *AuditService) Write(entry AuditEntry) { + raw, _ := json.Marshal(entry.Metadata) + _ = s.db.Create(&model.AuditLog{ + RequestID: entry.RequestID, + ActorUserID: entry.ActorUserID, + ActorUsername: entry.ActorUsername, + ClientIP: entry.ClientIP, + UserAgent: entry.UserAgent, + Action: entry.Action, + ResourceType: entry.ResourceType, + ResourceID: entry.ResourceID, + ScopeType: entry.ScopeType, + ScopeID: entry.ScopeID, + BusinessLineID: entry.BusinessLineID, + NamespaceID: entry.NamespaceID, + ClusterID: entry.ClusterID, + EnvironmentID: entry.EnvironmentID, + Decision: entry.Decision, + Reason: entry.Reason, + Metadata: string(raw), + }).Error +} diff --git a/authserver/internal/service/auth.go b/authserver/internal/service/auth.go new file mode 100644 index 0000000..91b8f62 --- /dev/null +++ b/authserver/internal/service/auth.go @@ -0,0 +1,245 @@ +package service + +import ( + "errors" + "fmt" + "net/mail" + "regexp" + "strings" + "time" + + "authserver/internal/auth" + "authserver/internal/config" + "authserver/internal/model" + "authserver/internal/sso" + + "gorm.io/gorm" +) + +var ( + ErrInvalidCredential = errors.New("invalid username or password") + ErrUserDisabled = errors.New("user disabled") + ErrSAMLSubjectMissing = errors.New("saml subject is missing") +) + +type AuthService struct { + cfg config.Config + db *gorm.DB + audit *AuditService +} + +type LoginResult struct { + Token string `json:"token"` + TokenID string `json:"token_id"` + ExpiresAt time.Time `json:"expires_at"` + User model.User `json:"user"` +} + +func NewAuthService(cfg config.Config, db *gorm.DB, audit *AuditService) *AuthService { + return &AuthService{cfg: cfg, db: db, audit: audit} +} + +func (s *AuthService) SAMLLogin(info *sso.SAMLDebugInfo, clientIP, userAgent string) (*LoginResult, error) { + subject := strings.TrimSpace(info.NameID) + email := firstSAMLAttribute(info.Attributes, + "email", + "mail", + "Email", + "EmailAddress", + "emailAddress", + "urn:oid:0.9.2342.19200300.100.1.3", + "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", + ) + if email == "" && looksLikeEmail(subject) { + email = subject + } + if subject == "" { + subject = email + } + if subject == "" { + reason := fmt.Sprintf("subject_missing name_id=%q attribute_keys=%v", info.NameID, samlAttributeKeys(info.Attributes)) + s.audit.Write(AuditEntry{ClientIP: clientIP, UserAgent: userAgent, Action: "saml.login.failed", Decision: "deny", Reason: reason}) + return nil, ErrSAMLSubjectMissing + } + + displayName := firstSAMLAttribute(info.Attributes, + "displayName", + "name", + "cn", + "username", + "uid", + "urn:oid:2.5.4.3", + "urn:oid:0.9.2342.19200300.100.1.1", + ) + + var result *LoginResult + err := s.db.Transaction(func(tx *gorm.DB) error { + user, err := findOrCreateSAMLUser(tx, subject, email, displayName) + if err != nil { + return err + } + if user.Status != "active" { + s.audit.Write(AuditEntry{ActorUserID: user.ID, ActorUsername: user.Username, ClientIP: clientIP, UserAgent: userAgent, Action: "saml.login.failed", Decision: "deny", Reason: "user_disabled"}) + return ErrUserDisabled + } + + token, tokenID, expiresAt, err := auth.Sign(s.cfg.JWTSecret, s.cfg.JWTIssuer, s.cfg.JWTTTL(), user.ID, user.Username, user.Email, user.IsAdmin) + if err != nil { + return err + } + + now := time.Now() + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{ + "last_login_at": now, + "email": user.Email, + "display_name": user.DisplayName, + }).Error; err != nil { + return err + } + if err := tx.Create(&model.AccessToken{ + UserID: user.ID, + TokenID: tokenID, + TokenType: "access", + ClientIP: clientIP, + UserAgent: userAgent, + ExpiresAt: expiresAt, + }).Error; err != nil { + return err + } + + user.LastLoginAt = &now + result = &LoginResult{Token: token, TokenID: tokenID, ExpiresAt: expiresAt, User: user} + return nil + }) + if err != nil { + return nil, err + } + + s.audit.Write(AuditEntry{ActorUserID: result.User.ID, ActorUsername: result.User.Username, ClientIP: clientIP, UserAgent: userAgent, Action: "saml.login.success", Decision: "allow"}) + return result, nil +} + +func findOrCreateSAMLUser(tx *gorm.DB, subject, email, displayName string) (model.User, error) { + username := samlUsername(email, subject) + if displayName == "" { + displayName = username + } + + var user model.User + if err := findExistingSAMLUser(tx, &user, username, email, subject); err == nil { + return updateSAMLUser(tx, user, username, email, displayName, subject) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, err + } + + user = model.User{ + Username: username, + DisplayName: displayName, + Email: email, + Source: "saml", + ExternalID: subject, + Status: "active", + IsAdmin: false, + } + if err := tx.Create(&user).Error; err != nil { + return user, err + } + return user, nil +} + +func findExistingSAMLUser(tx *gorm.DB, user *model.User, username, email, subject string) error { + query := tx.Where("username = ? AND deleted_at IS NULL", username) + if email != "" { + query = query.Or("email = ? AND deleted_at IS NULL", email) + } + if subject != "" { + query = query.Or("source = ? AND external_id = ? AND deleted_at IS NULL", "saml", subject) + } + return query.First(user).Error +} + +func updateSAMLUser(tx *gorm.DB, user model.User, username, email, displayName, subject string) (model.User, error) { + updates := map[string]any{ + "source": "saml", + "external_id": subject, + } + user.Source = "saml" + user.ExternalID = subject + + if email != "" && user.Email != email { + updates["email"] = email + user.Email = email + } + if displayName != "" && user.DisplayName != displayName { + updates["display_name"] = displayName + user.DisplayName = displayName + } + if username != "" && user.Username != username { + var count int64 + if err := tx.Model(&model.User{}).Where("username = ? AND id <> ? AND deleted_at IS NULL", username, user.ID).Count(&count).Error; err != nil { + return user, err + } + if count == 0 { + updates["username"] = username + user.Username = username + } + } + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { + return user, err + } + return user, nil +} + +func firstSAMLAttribute(attrs map[string][]string, names ...string) string { + for _, name := range names { + for _, value := range attrs[name] { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + } + for key, values := range attrs { + lower := strings.ToLower(key) + if strings.Contains(lower, "email") || strings.Contains(lower, "mail") { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + } + } + return "" +} + +func looksLikeEmail(value string) bool { + if value == "" { + return false + } + _, err := mail.ParseAddress(value) + return err == nil && strings.Contains(value, "@") +} + +var usernameCleaner = regexp.MustCompile(`[^a-zA-Z0-9@._-]+`) + +func samlUsername(email, subject string) string { + base := strings.TrimSpace(email) + if base == "" { + base = strings.TrimSpace(subject) + } + base = usernameCleaner.ReplaceAllString(base, "-") + base = strings.Trim(base, ".-_") + if base == "" { + return "saml-user" + } + return base +} + +func samlAttributeKeys(attrs map[string][]string) []string { + keys := make([]string, 0, len(attrs)) + for key := range attrs { + keys = append(keys, key) + } + return keys +} diff --git a/authserver/internal/service/wayen.go b/authserver/internal/service/wayen.go new file mode 100644 index 0000000..a21f2d3 --- /dev/null +++ b/authserver/internal/service/wayen.go @@ -0,0 +1,143 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "authserver/internal/config" + "authserver/internal/model" + + "gorm.io/gorm" +) + +var ( + ErrWayenNotConfigured = errors.New("wayen login is not configured") + ErrWayenEmailMissing = errors.New("email is missing in token") + ErrWayenCredentialNotFound = errors.New("wayen credential not found") + ErrWayenLoginFailed = errors.New("wayen login failed") +) + +type WayenService struct { + cfg config.Config + db *gorm.DB + client *http.Client +} + +type WayenLoginResult struct { + TargetURL string + Cookies []*http.Cookie +} + +func NewWayenService(cfg config.Config, db *gorm.DB) *WayenService { + return &WayenService{ + cfg: cfg, + db: db, + client: &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +func (s *WayenService) Login(email, username string) (*WayenLoginResult, error) { + email = strings.TrimSpace(email) + if email == "" { + return nil, ErrWayenEmailMissing + } + if strings.TrimSpace(s.cfg.WayenLoginURL) == "" || strings.TrimSpace(s.cfg.WayenTargetURL) == "" { + return nil, ErrWayenNotConfigured + } + + var credential model.WayenCredential + if err := s.db.Where("email = ? AND enabled = ?", email, true).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWayenCredentialNotFound + } + return nil, err + } + + loginName := email + if strings.EqualFold(s.cfg.WayenLoginValue, "username") && strings.TrimSpace(username) != "" { + loginName = strings.TrimSpace(username) + } + + loginURL, body, contentType, err := s.loginRequest(s.cfg.WayenLoginURL, loginName, credential.Password) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, loginURL, body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8") + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("%w: status %d", ErrWayenLoginFailed, resp.StatusCode) + } + + return &WayenLoginResult{ + TargetURL: s.cfg.WayenTargetURL, + Cookies: resp.Cookies(), + }, nil +} + +func (s *WayenService) loginRequest(loginURL, email, password string) (string, io.Reader, string, error) { + usernameKey := defaultConfigValue(s.cfg.WayenUsernameKey, "email") + passwordKey := defaultConfigValue(s.cfg.WayenPasswordKey, "password") + + if strings.EqualFold(s.cfg.WayenLoginFormat, "query") { + parsed, err := url.Parse(loginURL) + if err != nil { + return "", nil, "", err + } + values := parsed.Query() + values.Set(usernameKey, email) + values.Set(passwordKey, password) + parsed.RawQuery = values.Encode() + return parsed.String(), nil, "", nil + } + + if strings.EqualFold(s.cfg.WayenLoginFormat, "json") { + payload := map[string]string{ + usernameKey: email, + passwordKey: password, + } + data, err := json.Marshal(payload) + if err != nil { + return "", nil, "", err + } + return loginURL, bytes.NewReader(data), "application/json", nil + } + + values := url.Values{} + values.Set(usernameKey, email) + values.Set(passwordKey, password) + return loginURL, strings.NewReader(values.Encode()), "application/x-www-form-urlencoded", nil +} + +func defaultConfigValue(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/authserver/internal/sso/login.go b/authserver/internal/sso/login.go new file mode 100644 index 0000000..c863fe4 --- /dev/null +++ b/authserver/internal/sso/login.go @@ -0,0 +1,415 @@ +package sso + +import ( + "bytes" + "compress/flate" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const ( + samlHTTPRedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + samlHTTPPostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" +) + +type LoginConfig struct { + EntityID string + ACSURL string + IDPMetadataURL string + RelayState string +} + +func BuildLoginRedirect(cfg LoginConfig) (string, error) { + if cfg.EntityID == "" { + return "", errors.New("saml entity id is required") + } + if cfg.ACSURL == "" { + return "", errors.New("saml acs url is required") + } + ssoURL, err := ReadIDPSSOURL(cfg.IDPMetadataURL) + if err != nil { + return "", err + } + request, err := buildAuthnRequest(cfg.EntityID, cfg.ACSURL, ssoURL) + if err != nil { + return "", err + } + encoded, err := deflateAndBase64(request) + if err != nil { + return "", err + } + redirectURL, err := url.Parse(ssoURL) + if err != nil { + return "", err + } + query := redirectURL.Query() + query.Set("SAMLRequest", encoded) + if cfg.RelayState != "" { + query.Set("RelayState", cfg.RelayState) + } + redirectURL.RawQuery = query.Encode() + return redirectURL.String(), nil +} + +func ReadIDPSSOURL(metadataURL string) (string, error) { + if metadataURL == "" { + return "", errors.New("saml idp metadata url is required") + } + request, err := http.NewRequest(http.MethodGet, metadataURL, nil) + if err != nil { + return "", err + } + request.Header.Set("Accept", "application/samlmetadata+xml, application/xml, text/xml, */*") + request.Header.Set("User-Agent", "authserver-saml-metadata/1.0") + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return "", err + } + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("saml idp metadata request failed: url=%s status=%s body=%q", request.URL.String(), response.Status, truncateForLog(string(raw), 300)) + } + return readIDPSSOURL(raw) +} + +func truncateForLog(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 || len(value) <= limit { + return value + } + return value[:limit] + "...(truncated)" +} + +func readIDPSSOURL(raw []byte) (string, error) { + var doc idpEntityDescriptor + if err := xml.Unmarshal(raw, &doc); err != nil { + return "", err + } + for _, service := range doc.IDPSSODescriptor.SingleSignOnServices { + if service.Binding == samlHTTPRedirectBinding && service.Location != "" { + return service.Location, nil + } + } + for _, service := range doc.IDPSSODescriptor.SingleSignOnServices { + if service.Location != "" { + return service.Location, nil + } + } + return "", errors.New("saml idp metadata has no single sign on service") +} + +func DecodeSAMLResponse(value, privateKeyFile string) (*SAMLDebugInfo, error) { + if value == "" { + return nil, errors.New("SAMLResponse is required") + } + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, err + } + info := &SAMLDebugInfo{ + RawXML: string(raw), + } + _ = xml.Unmarshal(raw, &info.Response) + if info.Response.Assertion.Subject.NameID.Value == "" && info.Response.EncryptedAssertion.EncryptedData.CipherData.CipherValue != "" { + assertionXML, assertion, err := decryptEncryptedAssertion(info.Response.EncryptedAssertion, privateKeyFile) + if err != nil { + return nil, err + } + info.DecryptedAssertionXML = assertionXML + info.Response.EncryptedAssertion.Assertion = assertion + } + info.NameID = firstNameID(info.Response.Assertion.Subject.NameID.Value, info.Response.EncryptedAssertion.Assertion.Subject.NameID.Value) + info.Attributes = make(map[string][]string) + for _, attr := range info.Response.Assertion.AttributeStatement.Attributes { + addSAMLAttributeValues(info.Attributes, attr) + } + for _, attr := range info.Response.EncryptedAssertion.Assertion.AttributeStatement.Attributes { + addSAMLAttributeValues(info.Attributes, attr) + } + return info, nil +} + +func addSAMLAttributeValues(attrs map[string][]string, attr attribute) { + if attr.Name != "" { + attrs[attr.Name] = append(attrs[attr.Name], attr.Values...) + } + if attr.FriendlyName != "" && attr.FriendlyName != attr.Name { + attrs[attr.FriendlyName] = append(attrs[attr.FriendlyName], attr.Values...) + } +} + +func decryptEncryptedAssertion(encrypted encryptedAssertion, privateKeyFile string) (string, assertion, error) { + key, err := readRSAPrivateKey(privateKeyFile) + if err != nil { + return "", assertion{}, err + } + encryptedKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encrypted.EncryptedData.EncryptedKey.CipherData.CipherValue)) + if err != nil { + return "", assertion{}, fmt.Errorf("decode saml encrypted key: %w", err) + } + sessionKey, err := rsa.DecryptOAEP(sha1.New(), rand.Reader, key, encryptedKey, nil) + if err != nil { + return "", assertion{}, fmt.Errorf("decrypt saml encrypted key: %w", err) + } + encryptedValue, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encrypted.EncryptedData.CipherData.CipherValue)) + if err != nil { + return "", assertion{}, fmt.Errorf("decode saml encrypted assertion: %w", err) + } + plain, err := decryptAESCBC(encryptedValue, sessionKey) + if err != nil { + return "", assertion{}, err + } + assertionXML := string(plain) + var out assertion + if err := xml.Unmarshal(plain, &out); err != nil { + return "", assertion{}, fmt.Errorf("parse decrypted saml assertion: %w", err) + } + return assertionXML, out, nil +} + +func readRSAPrivateKey(path string) (*rsa.PrivateKey, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("saml sp key file is required for encrypted assertion") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(raw) + if block == nil { + return nil, errors.New("saml sp key file has no PEM block") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("saml sp key is not an RSA private key") + } + return key, nil +} + +func decryptAESCBC(value, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("init saml assertion cipher: %w", err) + } + if len(value) < block.BlockSize()*2 || len(value)%block.BlockSize() != 0 { + return nil, errors.New("invalid saml encrypted assertion length") + } + iv := value[:block.BlockSize()] + cipherText := value[block.BlockSize():] + plain := make([]byte, len(cipherText)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, cipherText) + plain, err = pkcs7Unpad(plain, block.BlockSize()) + if err != nil { + return nil, err + } + return plain, nil +} + +func pkcs7Unpad(value []byte, blockSize int) ([]byte, error) { + if len(value) == 0 || len(value)%blockSize != 0 { + return nil, errors.New("invalid saml assertion padding length") + } + padding := int(value[len(value)-1]) + if padding == 0 || padding > blockSize || padding > len(value) { + return nil, errors.New("invalid saml assertion padding") + } + for _, b := range value[len(value)-padding:] { + if int(b) != padding { + return nil, errors.New("invalid saml assertion padding bytes") + } + } + return value[:len(value)-padding], nil +} + +func (i *SAMLDebugInfo) JSON() string { + data, _ := json.MarshalIndent(map[string]any{ + "id": i.Response.ID, + "in_response_to": i.Response.InResponseTo, + "issuer": i.Response.Issuer, + "destination": i.Response.Destination, + "name_id": i.NameID, + "attributes": i.Attributes, + }, "", " ") + return string(data) +} + +func buildAuthnRequest(entityID, acsURL, destination string) ([]byte, error) { + req := authnRequest{ + XMLName: xml.Name{Local: "samlp:AuthnRequest"}, + XmlnsSAMLp: samlProtocolNS, + XmlnsSAML: "urn:oasis:names:tc:SAML:2.0:assertion", + ID: "_" + randomHex(20), + Version: "2.0", + IssueInstant: time.Now().UTC().Format(time.RFC3339), + Destination: destination, + AssertionConsumerServiceURL: acsURL, + ProtocolBinding: samlHTTPPostBinding, + Issuer: issuer{ + Value: entityID, + }, + NameIDPolicy: nameIDPolicy{ + Format: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + AllowCreate: true, + }, + } + return xml.Marshal(req) +} + +func deflateAndBase64(raw []byte) (string, error) { + var buf bytes.Buffer + writer, err := flate.NewWriter(&buf, flate.DefaultCompression) + if err != nil { + return "", err + } + if _, err := writer.Write(raw); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +func randomHex(size int) string { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func firstNameID(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +type idpEntityDescriptor struct { + IDPSSODescriptor struct { + SingleSignOnServices []struct { + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` + } `xml:"SingleSignOnService"` + } `xml:"IDPSSODescriptor"` +} + +type authnRequest struct { + XMLName xml.Name `xml:"samlp:AuthnRequest"` + XmlnsSAMLp string `xml:"xmlns:samlp,attr"` + XmlnsSAML string `xml:"xmlns:saml,attr"` + ID string `xml:"ID,attr"` + Version string `xml:"Version,attr"` + IssueInstant string `xml:"IssueInstant,attr"` + Destination string `xml:"Destination,attr"` + AssertionConsumerServiceURL string `xml:"AssertionConsumerServiceURL,attr"` + ProtocolBinding string `xml:"ProtocolBinding,attr"` + Issuer issuer `xml:"saml:Issuer"` + NameIDPolicy nameIDPolicy `xml:"samlp:NameIDPolicy"` +} + +type issuer struct { + Value string `xml:",chardata"` +} + +type nameIDPolicy struct { + Format string `xml:"Format,attr"` + AllowCreate bool `xml:"AllowCreate,attr"` +} + +type SAMLDebugInfo struct { + RawXML string + DecryptedAssertionXML string + NameID string + Attributes map[string][]string + Response samlResponse +} + +type samlResponse struct { + ID string `xml:"ID,attr"` + InResponseTo string `xml:"InResponseTo,attr"` + Destination string `xml:"Destination,attr"` + Issuer string `xml:"Issuer"` + Assertion assertion `xml:"Assertion"` + EncryptedAssertion encryptedAssertion `xml:"EncryptedAssertion"` +} + +type encryptedAssertion struct { + Assertion assertion `xml:"Assertion"` + EncryptedData encryptedData `xml:"EncryptedData"` +} + +type encryptedData struct { + EncryptionMethod struct { + Algorithm string `xml:"Algorithm,attr"` + } `xml:"EncryptionMethod"` + EncryptedKey encryptedKey `xml:"KeyInfo>EncryptedKey"` + CipherData cipherData `xml:"CipherData"` +} + +type encryptedKey struct { + EncryptionMethod struct { + Algorithm string `xml:"Algorithm,attr"` + } `xml:"EncryptionMethod"` + CipherData cipherData `xml:"CipherData"` +} + +type cipherData struct { + CipherValue string `xml:"CipherValue"` +} + +type assertion struct { + Subject subject `xml:"Subject"` + AttributeStatement attributeStatement `xml:"AttributeStatement"` +} + +type subject struct { + NameID nameID `xml:"NameID"` +} + +type nameID struct { + Value string `xml:",chardata"` +} + +type attributeStatement struct { + Attributes []attribute `xml:"Attribute"` +} + +type attribute struct { + Name string `xml:"Name,attr"` + FriendlyName string `xml:"FriendlyName,attr"` + Values []string `xml:"AttributeValue"` +} diff --git a/authserver/internal/sso/login_test.go b/authserver/internal/sso/login_test.go new file mode 100644 index 0000000..eb1d3c3 --- /dev/null +++ b/authserver/internal/sso/login_test.go @@ -0,0 +1,190 @@ +package sso + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" +) + +func TestReadIDPSSOURLFetchesMetadataURL(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method: %s", r.Method) + } + _, _ = w.Write([]byte(testIDPMetadataXML( + "https://idp.example.com/saml2/meta", + "https://idp.example.com/sso/redirect", + "https://idp.example.com/sso/post", + ))) + })) + defer server.Close() + + got, err := ReadIDPSSOURL(server.URL) + if err != nil { + t.Fatalf("ReadIDPSSOURL returned error: %v", err) + } + if got != "https://idp.example.com/sso/redirect" { + t.Fatalf("unexpected sso url: %s", got) + } +} + +func TestReadIDPSSOURLReturnsHTTPError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + })) + defer server.Close() + + if _, err := ReadIDPSSOURL(server.URL); err == nil { + t.Fatal("expected error for non-200 metadata response") + } +} + +func TestBuildLoginRedirectUsesRemoteMetadata(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(testIDPMetadataXML( + "https://idp.example.com/saml2/meta", + "https://idp.example.com/sso/redirect", + "https://idp.example.com/sso/post", + ))) + })) + defer server.Close() + + redirectURL, err := BuildLoginRedirect(LoginConfig{ + EntityID: "https://sp.example.com/auth/api/v1/saml/metadata", + ACSURL: "https://sp.example.com/auth/api/v1/saml/acs", + IDPMetadataURL: server.URL, + RelayState: "/auth/", + }) + if err != nil { + t.Fatalf("BuildLoginRedirect returned error: %v", err) + } + + parsed, err := url.Parse(redirectURL) + if err != nil { + t.Fatalf("parse redirect url: %v", err) + } + if parsed.String() == "" { + t.Fatal("redirect url is empty") + } + if parsed.Scheme != "https" || parsed.Host != "idp.example.com" || parsed.Path != "/sso/redirect" { + t.Fatalf("unexpected redirect target: %s", redirectURL) + } + if parsed.Query().Get("SAMLRequest") == "" { + t.Fatal("missing SAMLRequest query") + } + if parsed.Query().Get("RelayState") != "/auth/" { + t.Fatalf("unexpected relay state: %s", parsed.Query().Get("RelayState")) + } +} + +func TestDecodeSAMLResponseDecryptsEncryptedAssertion(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyFile := writeTestPrivateKey(t, key) + + assertion := ` + alice@example.com + + alice@example.com + +` + sessionKey := []byte("0123456789abcdef") + encryptedAssertion := encryptTestAssertion(t, []byte(assertion), sessionKey) + encryptedKey, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &key.PublicKey, sessionKey, nil) + if err != nil { + t.Fatalf("encrypt key: %v", err) + } + response := ` + + + + + ` + base64.StdEncoding.EncodeToString(encryptedKey) + ` + + + ` + base64.StdEncoding.EncodeToString(encryptedAssertion) + ` + + +` + + info, err := DecodeSAMLResponse(base64.StdEncoding.EncodeToString([]byte(response)), keyFile) + if err != nil { + t.Fatalf("DecodeSAMLResponse returned error: %v", err) + } + if info.NameID != "alice@example.com" { + t.Fatalf("unexpected name id: %q", info.NameID) + } + if got := info.Attributes["email"]; len(got) != 1 || got[0] != "alice@example.com" { + t.Fatalf("unexpected email attribute: %#v", got) + } + if info.DecryptedAssertionXML == "" { + t.Fatal("missing decrypted assertion xml") + } +} + +func writeTestPrivateKey(t *testing.T, key *rsa.PrivateKey) string { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "sp-*.key") + if err != nil { + t.Fatalf("create key file: %v", err) + } + err = pem.Encode(file, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + closeErr := file.Close() + if err != nil { + t.Fatalf("write key file: %v", err) + } + if closeErr != nil { + t.Fatalf("close key file: %v", closeErr) + } + return file.Name() +} + +func encryptTestAssertion(t *testing.T, plain, key []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("init cipher: %v", err) + } + plain = pkcs7Pad(plain, block.BlockSize()) + iv := bytes.Repeat([]byte{1}, block.BlockSize()) + out := make([]byte, len(iv)+len(plain)) + copy(out, iv) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out[len(iv):], plain) + return out +} + +func pkcs7Pad(value []byte, blockSize int) []byte { + padding := blockSize - len(value)%blockSize + return append(value, bytes.Repeat([]byte{byte(padding)}, padding)...) +} + +func testIDPMetadataXML(entityID, redirectURL, postURL string) string { + return ` + + + + + +` +} diff --git a/authserver/internal/sso/metadata.go b/authserver/internal/sso/metadata.go new file mode 100644 index 0000000..5752ad3 --- /dev/null +++ b/authserver/internal/sso/metadata.go @@ -0,0 +1,147 @@ +package sso + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "encoding/xml" + "errors" + "os" + "strings" +) + +const ( + samlMetadataNS = "urn:oasis:names:tc:SAML:2.0:metadata" + samlProtocolNS = "urn:oasis:names:tc:SAML:2.0:protocol" + xmlDsigNS = "http://www.w3.org/2000/09/xmldsig#" +) + +type MetadataConfig struct { + EntityID string + ACSURL string + CertFile string +} + +func BuildSPMetadata(cfg MetadataConfig) ([]byte, error) { + if cfg.EntityID == "" { + return nil, errors.New("saml entity id is required") + } + if cfg.ACSURL == "" { + return nil, errors.New("saml acs url is required") + } + cert, err := readCertificate(cfg.CertFile) + if err != nil { + return nil, err + } + + doc := entityDescriptor{ + XMLName: xml.Name{Local: "EntityDescriptor"}, + Xmlns: samlMetadataNS, + EntityID: cfg.EntityID, + SPSSODescriptor: spSSODescriptor{ + ProtocolSupportEnumeration: samlProtocolNS, + AuthnRequestsSigned: true, + WantAssertionsSigned: true, + KeyDescriptors: []keyDescriptor{ + newKeyDescriptor("signing", cert), + newKeyDescriptor("encryption", cert), + }, + NameIDFormats: []string{ + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + }, + AssertionConsumerServices: []assertionConsumerService{ + { + Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + Location: cfg.ACSURL, + Index: 0, + IsDefault: true, + }, + }, + }, + } + + body, err := xml.MarshalIndent(doc, "", " ") + if err != nil { + return nil, err + } + out := append([]byte(xml.Header), body...) + out = append(out, '\n') + return out, nil +} + +func readCertificate(path string) (string, error) { + if path == "" { + return "", errors.New("saml sp cert file is required") + } + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + block, _ := pem.Decode(raw) + if block == nil { + return stripPEMCertificate(string(raw)), nil + } + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(block.Bytes), nil +} + +func stripPEMCertificate(value string) string { + value = strings.ReplaceAll(value, "-----BEGIN CERTIFICATE-----", "") + value = strings.ReplaceAll(value, "-----END CERTIFICATE-----", "") + value = strings.ReplaceAll(value, "\n", "") + value = strings.ReplaceAll(value, "\r", "") + value = strings.ReplaceAll(value, " ", "") + return value +} + +func newKeyDescriptor(use string, cert string) keyDescriptor { + return keyDescriptor{ + Use: use, + KeyInfo: keyInfo{ + Xmlns: xmlDsigNS, + X509Data: x509Data{ + X509Certificate: cert, + }, + }, + } +} + +type entityDescriptor struct { + XMLName xml.Name `xml:"EntityDescriptor"` + Xmlns string `xml:"xmlns,attr"` + EntityID string `xml:"entityID,attr"` + SPSSODescriptor spSSODescriptor `xml:"SPSSODescriptor"` +} + +type spSSODescriptor struct { + ProtocolSupportEnumeration string `xml:"protocolSupportEnumeration,attr"` + AuthnRequestsSigned bool `xml:"AuthnRequestsSigned,attr"` + WantAssertionsSigned bool `xml:"WantAssertionsSigned,attr"` + KeyDescriptors []keyDescriptor `xml:"KeyDescriptor"` + NameIDFormats []string `xml:"NameIDFormat"` + AssertionConsumerServices []assertionConsumerService `xml:"AssertionConsumerService"` +} + +type keyDescriptor struct { + Use string `xml:"use,attr"` + KeyInfo keyInfo `xml:"KeyInfo"` +} + +type keyInfo struct { + Xmlns string `xml:"xmlns,attr"` + X509Data x509Data `xml:"X509Data"` +} + +type x509Data struct { + X509Certificate string `xml:"X509Certificate"` +} + +type assertionConsumerService struct { + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` + Index int `xml:"index,attr"` + IsDefault bool `xml:"isDefault,attr"` +} diff --git a/authserver/web/.dockerignore b/authserver/web/.dockerignore new file mode 100644 index 0000000..a7e5733 --- /dev/null +++ b/authserver/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store + diff --git a/authserver/web/Dockerfile b/authserver/web/Dockerfile new file mode 100644 index 0000000..debcaaf --- /dev/null +++ b/authserver/web/Dockerfile @@ -0,0 +1,16 @@ +FROM node:14-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --no-audit --no-fund + +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html/auth + +EXPOSE 80 diff --git a/authserver/web/index.html b/authserver/web/index.html new file mode 100644 index 0000000..f469a73 --- /dev/null +++ b/authserver/web/index.html @@ -0,0 +1,12 @@ + + + + + + AuthServer + + +
+ + + diff --git a/authserver/web/nginx.conf b/authserver/web/nginx.conf new file mode 100644 index 0000000..0d567c7 --- /dev/null +++ b/authserver/web/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 80; + server_name _; + + client_max_body_size 10m; + + root /usr/share/nginx/html; + index index.html; + + location = / { + return 302 /auth/; + } + + location = /healthz { + proxy_pass http://authserver-backend:8083; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /readyz { + proxy_pass http://authserver-backend:8083; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /auth/api/ { + proxy_pass http://authserver-backend:8083; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /auth/ { + try_files $uri $uri/ /auth/index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { + expires 7d; + add_header Cache-Control "public, max-age=604800"; + try_files $uri =404; + } +} diff --git a/authserver/web/package-lock.json b/authserver/web/package-lock.json new file mode 100644 index 0000000..6c2df8d --- /dev/null +++ b/authserver/web/package-lock.json @@ -0,0 +1,379 @@ +{ + "name": "authserver-web", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" + }, + "@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" + }, + "@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "requires": { + "@babel/types": "^7.29.7" + } + }, + "@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "requires": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + } + }, + "@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "optional": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==" + }, + "@vue/compiler-core": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.17.tgz", + "integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==", + "requires": { + "@babel/parser": "^7.27.5", + "@vue/shared": "3.5.17", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "@vue/compiler-dom": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz", + "integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==", + "requires": { + "@vue/compiler-core": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "@vue/compiler-sfc": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz", + "integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==", + "requires": { + "@babel/parser": "^7.27.5", + "@vue/compiler-core": "3.5.17", + "@vue/compiler-dom": "3.5.17", + "@vue/compiler-ssr": "3.5.17", + "@vue/shared": "3.5.17", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "@vue/compiler-ssr": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz", + "integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==", + "requires": { + "@vue/compiler-dom": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "@vue/reactivity": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz", + "integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==", + "requires": { + "@vue/shared": "3.5.17" + } + }, + "@vue/runtime-core": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.17.tgz", + "integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==", + "requires": { + "@vue/reactivity": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "@vue/runtime-dom": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz", + "integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==", + "requires": { + "@vue/reactivity": "3.5.17", + "@vue/runtime-core": "3.5.17", + "@vue/shared": "3.5.17", + "csstype": "^3.1.3" + } + }, + "@vue/server-renderer": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.17.tgz", + "integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==", + "requires": { + "@vue/compiler-ssr": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "@vue/shared": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.17.tgz", + "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==" + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "requires": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "optional": true + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==" + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "postcss": { + "version": "8.5.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", + "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "requires": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "requires": { + "fsevents": "~2.3.2" + } + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "vite": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "requires": { + "esbuild": "^0.18.10", + "fsevents": "~2.3.2", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + } + }, + "vue": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz", + "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==", + "requires": { + "@vue/compiler-dom": "3.5.17", + "@vue/compiler-sfc": "3.5.17", + "@vue/runtime-dom": "3.5.17", + "@vue/server-renderer": "3.5.17", + "@vue/shared": "3.5.17" + } + } + } +} diff --git a/authserver/web/package.json b/authserver/web/package.json new file mode 100644 index 0000000..3f7d557 --- /dev/null +++ b/authserver/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "authserver-web", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=14.18.0" + }, + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@vitejs/plugin-vue": "4.6.2", + "vite": "4.5.3", + "vue": "3.5.17" + }, + "devDependencies": {} +} diff --git a/authserver/web/src/App.vue b/authserver/web/src/App.vue new file mode 100644 index 0000000..84d7f92 --- /dev/null +++ b/authserver/web/src/App.vue @@ -0,0 +1,211 @@ + + + diff --git a/authserver/web/src/main.js b/authserver/web/src/main.js new file mode 100644 index 0000000..2425c0f --- /dev/null +++ b/authserver/web/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/authserver/web/src/style.css b/authserver/web/src/style.css new file mode 100644 index 0000000..e6af69c --- /dev/null +++ b/authserver/web/src/style.css @@ -0,0 +1,230 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + color: #172033; + background: #f5f7fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +button, +input { + font: inherit; +} + +button { + min-height: 36px; + border: 1px solid #cfd7e6; + border-radius: 6px; + background: #ffffff; + color: #172033; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input { + width: 100%; + min-height: 36px; + border: 1px solid #cfd7e6; + border-radius: 6px; + padding: 0 10px; + background: #ffffff; + color: #172033; +} + +h1, +p { + margin: 0; +} + +h1 { + font-size: 22px; + line-height: 1.2; +} + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.login-panel { + width: min(420px, 100%); + display: grid; + gap: 22px; + border: 1px solid #dbe3ef; + border-radius: 8px; + padding: 28px; + background: #ffffff; + box-shadow: 0 18px 50px rgb(43 57 84 / 10%); +} + +.form { + display: grid; + gap: 10px; +} + +.stack { + gap: 14px; +} + +.form label span { + display: block; + margin-bottom: 6px; + color: #526174; + font-size: 13px; +} + +.primary { + border-color: #1b64d8; + background: #1b64d8; + color: #ffffff; + font-weight: 650; +} + +.secondary { + padding: 0 14px; + border-color: #b8c5d9; + background: #ffffff; +} + +.eyebrow { + margin-bottom: 6px; + color: #526174; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.message { + margin-top: 4px; + color: #31516f; + font-size: 13px; +} + +.wayen-page { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + background: + linear-gradient(135deg, rgb(22 82 170 / 8%), transparent 42%), + #f5f7fb; +} + +.wayen-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 22px 28px; + border-bottom: 1px solid #dbe3ef; + background: rgb(255 255 255 / 86%); + backdrop-filter: blur(10px); +} + +.wayen-main { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 360px)); + justify-content: center; + align-items: center; + gap: 18px; + padding: 32px 20px; +} + +.wayen-button { + width: min(280px, 78vw); + aspect-ratio: 1; + display: grid; + place-items: center; + align-content: center; + gap: 18px; + border-color: #cfdaf0; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 18px 50px rgb(43 57 84 / 12%); +} + +.wayen-button:hover:not(:disabled), +.wayen-button:focus-visible { + border-color: #1b64d8; + box-shadow: 0 22px 60px rgb(27 100 216 / 18%); + outline: none; +} + +.wayen-icon { + width: 112px; + height: 112px; + display: grid; + place-items: center; + border-radius: 28px; + background: #1b64d8; + color: #ffffff; + font-size: 64px; + font-weight: 800; + line-height: 1; +} + +.wayen-title { + color: #172033; + font-size: 22px; + font-weight: 750; +} + +.user-block { + display: flex; + align-items: center; + gap: 12px; + color: #526174; + font-size: 14px; +} + +.wayen-config { + width: min(360px, 100%); + padding: 22px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 18px 50px rgb(43 57 84 / 10%); +} + +.config-title { + color: #172033; + font-size: 16px; + font-weight: 700; +} + +@media (max-width: 560px) { + .wayen-header { + align-items: flex-start; + flex-direction: column; + padding: 18px 20px; + } + + .user-block { + width: 100%; + justify-content: space-between; + } + + .wayen-icon { + width: 92px; + height: 92px; + border-radius: 22px; + font-size: 52px; + } + + .wayen-main { + grid-template-columns: 1fr; + } +} diff --git a/authserver/web/vite.config.js b/authserver/web/vite.config.js new file mode 100644 index 0000000..6cb45ed --- /dev/null +++ b/authserver/web/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + base: '/auth/', + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/auth/api': 'http://127.0.0.1:8083', + '/healthz': 'http://127.0.0.1:8083', + '/readyz': 'http://127.0.0.1:8083', + }, + }, +})