认证: - 图形验证码 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 集成 + 容器内真实系统账号端到端验证
132 lines
3.8 KiB
Go
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
|
|
}
|