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 集成 + 容器内真实系统账号端到端验证
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user