Files
cao.wangrenbo 630d240dc0 feat(M1): 认证与用户管理 — 双通道登录、cookie 会话、用户 CRUD 与真实系统账号对接
认证:
- 图形验证码 GET /auth/captcha(内置 PNG 渲染,零第三方依赖)
- 外部用户 OTP 双通道:DB 存储(otp_codes)使邮件与 CLI 共用同一验证码/冷却/失败限速
- 管理员 bcrypt 登录 + 连续失败限速锁定;admin/forgot + admin/reset 邮件重置(SMTP 或日志)
- cookie 会话(HttpOnly/SameSite)、me/logout、admin/user 鉴权中间件

用户管理(admin):
- CRUD + disable/enable/extend/delete,对接 system 层真实 useradd/usermod/userdel/passwd
- system 层三执行模式:dry-run(默认,安全)/ direct(容器/测试用户)/ sudo(生产 sudoers 白名单)
- Exists 系统账号一致性检查;deploy/sudoers.example 白名单模板
- 关键操作接入 append-only 审计

其他:
- CLI user otp 改 DB store,与邮件通道真正对齐
- 容器镜像补 shadow(alpine 无 useradd);Makefile VERSION 0.2.0-m1
- 测试:auth/service 单测 + api httptest 集成 + 容器内真实系统账号端到端验证
2026-08-29 23:40:20 +08:00

132 lines
3.8 KiB
Go

package auth
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math/rand"
"time"
)
// 验证码图像渲染:内置 5x7 点阵数字 + 噪点 + 干扰线,不依赖第三方字体库
// (PLAN §4:内置生成,不依赖第三方服务)。
// digitGlyphs 为 0-9 的 5x7 点阵,每行低 5 位表示该行像素。
var digitGlyphs = [10][7]byte{
{0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110}, // 0
{0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110}, // 1
{0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111}, // 2
{0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110}, // 3
{0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010}, // 4
{0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110}, // 5
{0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110}, // 6
{0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000}, // 7
{0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110}, // 8
{0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100}, // 9
}
const (
glyphW = 5
glyphH = 7
scale = 3 // 点阵放大倍数
charGap = 4 // 字符间距(像素)
edgePad = 6 // 画布边距
)
// RenderCaptchaPNG 将 4 位数字验证码渲染为 PNG 字节流。
// 仅接受数字字符,其余返回错误。
func RenderCaptchaPNG(text string) ([]byte, error) {
if len(text) == 0 || len(text) > 8 {
return nil, fmt.Errorf("auth: captcha text length must be 1~8")
}
for _, r := range text {
if r < '0' || r > '9' {
return nil, fmt.Errorf("auth: captcha text must be digits")
}
}
canvasW := edgePad*2 + len(text)*glyphW*scale + (len(text)-1)*charGap
canvasH := edgePad*2 + glyphH*scale
img := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{248, 250, 252, 255}}, image.Point{}, draw.Src)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// 噪点(浅灰,稀疏)
for i := 0; i < canvasW*canvasH/7; i++ {
x, y := rng.Intn(canvasW), rng.Intn(canvasH)
g := uint8(150 + rng.Intn(90))
img.Set(x, y, color.RGBA{g, g, g, 255})
}
// 干扰线(穿过字符区域的浅色斜线)
for i := 0; i < 3; i++ {
g := uint8(160 + rng.Intn(80))
c := color.RGBA{g, g, g, 255}
x1, y1 := rng.Intn(canvasW/2), rng.Intn(canvasH)
x2, y2 := canvasW/2+rng.Intn(canvasW/2), rng.Intn(canvasH)
drawLine(img, x1, y1, x2, y2, c)
}
// 逐字符绘制(颜色随机取深色系)
inkPalette := []color.RGBA{
{40, 60, 110, 255}, {120, 45, 45, 255}, {30, 90, 60, 255}, {80, 60, 110, 255},
}
for i, r := range text {
glyph := digitGlyphs[r-'0']
ink := inkPalette[rng.Intn(len(inkPalette))]
x0 := edgePad + i*(glyphW*scale+charGap)
y0 := edgePad
for row := 0; row < glyphH; row++ {
for col := 0; col < glyphW; col++ {
if glyph[row]&(1<<(glyphW-1-col)) != 0 {
fillRect(img, x0+col*scale, y0+row*scale, scale, scale, ink)
}
}
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("auth: encode captcha png: %w", err)
}
return buf.Bytes(), nil
}
// fillRect 填充实心矩形。
func fillRect(img *image.RGBA, x, y, w, h int, c color.RGBA) {
for dy := 0; dy < h; dy++ {
for dx := 0; dx < w; dx++ {
img.Set(x+dx, y+dy, c)
}
}
}
// drawLine 使用 DDA 算法画线。
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) {
steps := abs(x2-x1)
if d := abs(y2 - y1); d > steps {
steps = d
}
if steps == 0 {
img.Set(x1, y1, c)
return
}
for i := 0; i <= steps; i++ {
x := x1 + (x2-x1)*i/steps
y := y1 + (y2-y1)*i/steps
if x >= 0 && x < img.Bounds().Dx() && y >= 0 && y < img.Bounds().Dy() {
img.Set(x, y, c)
}
}
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}