Improve installer extraction, login failures, and upload handling
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -65,6 +66,9 @@ func newReleaseUploadError(status int, code string, err error) error {
|
||||
|
||||
func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
|
||||
limit := r.releaseUploadLimit()
|
||||
if req.ContentLength > limit+(8<<20) {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("release package or form data exceeds upload limit"))
|
||||
}
|
||||
req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
|
||||
reader, err := req.MultipartReader()
|
||||
if err != nil {
|
||||
@@ -146,11 +150,18 @@ func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (rel
|
||||
}()
|
||||
hash := sha256.New()
|
||||
limited := &io.LimitedReader{R: part, N: limit + 1}
|
||||
written, err := io.Copy(tmp, io.TeeReader(limited, hash))
|
||||
written, err := io.Copy(io.MultiWriter(tmp, hash), limited)
|
||||
if err == nil {
|
||||
err = tmp.Sync()
|
||||
}
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
var pathErr *os.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
|
||||
}
|
||||
return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
|
||||
}
|
||||
if written > limit {
|
||||
@@ -179,6 +190,9 @@ func classifyMultipartReadError(err error) error {
|
||||
if errors.As(err, &maxErr) {
|
||||
return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, context.Canceled) {
|
||||
return newReleaseUploadError(http.StatusBadRequest, "UPLOAD_INTERRUPTED", errors.New("upload was interrupted before the package was complete"))
|
||||
}
|
||||
return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ func localizedErrorMessage(code, message string) string {
|
||||
byCode := map[string]string{
|
||||
"UNAUTHORIZED": "需要登录后继续操作",
|
||||
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
|
||||
"LOGIN_LOCKED": "登录失败次数过多,请 5 分钟后重试",
|
||||
"CAPTCHA_INVALID": "验证码错误或已过期,请输入新的验证码",
|
||||
"CREDENTIALS_INVALID": "账号或密码不正确",
|
||||
"LOGIN_TIMEOUT": "登录校验超时,请稍后重试",
|
||||
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
||||
"INVALID_PAYLOAD": "提交内容格式不正确",
|
||||
@@ -93,6 +96,11 @@ func localizedErrorMessage(code, message string) string {
|
||||
"NOTICE_VALIDATE_FAILED": "版本日志校验失败",
|
||||
"NOTICE_RESTORE_FAILED": "版本日志恢复失败",
|
||||
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
|
||||
"PACKAGE_EMPTY": "发布包不能为空",
|
||||
"PACKAGE_TOO_LARGE": "发布包超过服务端上传上限",
|
||||
"UPLOAD_STORAGE_FAILED": "服务端无法保存上传文件",
|
||||
"MANIFEST_UPDATE_FAILED": "发布包已回滚,更新清单写入失败",
|
||||
"UPLOAD_INTERRUPTED": "上传连接已中断,请重新上传",
|
||||
"SOURCE_SAVE_FAILED": "接口源保存失败",
|
||||
"CHECK_FAILED": "接口健康检测失败",
|
||||
"SYNC_FAILED": "同步操作失败",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -32,7 +33,7 @@ type router struct {
|
||||
publicSnapshots *publicSnapshotService
|
||||
}
|
||||
|
||||
const loginRequestTimeout = 5 * time.Second
|
||||
const loginRequestTimeout = 8 * time.Second
|
||||
|
||||
func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, feedbackService *feedback.Service, releaseService *releases.Service, sourceService *sources.Service, legacyService *legacy.Service, optional ...any) http.Handler {
|
||||
r := &router{
|
||||
@@ -189,6 +190,11 @@ func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
captcha, err := r.auth.NewCaptcha()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err)
|
||||
@@ -219,7 +225,8 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
|
||||
defer cancel()
|
||||
sessionID, csrf, ok, err := r.auth.Login(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
|
||||
clientAddress := remoteHost(req.RemoteAddr)
|
||||
sessionID, csrf, failure, err := r.auth.LoginDetailed(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, clientAddress)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
|
||||
@@ -228,13 +235,33 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusOK, "LOGIN_FAILED", errors.New("invalid password or captcha"))
|
||||
if failure != auth.LoginFailureNone {
|
||||
code := "LOGIN_FAILED"
|
||||
message := "invalid username or password"
|
||||
switch failure {
|
||||
case auth.LoginFailureLocked:
|
||||
code = "LOGIN_LOCKED"
|
||||
message = "too many login attempts; try again later"
|
||||
case auth.LoginFailureCaptcha:
|
||||
code = "CAPTCHA_INVALID"
|
||||
message = "captcha is invalid or expired"
|
||||
case auth.LoginFailureCredentials:
|
||||
code = "CREDENTIALS_INVALID"
|
||||
}
|
||||
writeError(w, http.StatusOK, code, errors.New(message))
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookieForRequest(w, req, sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
|
||||
go r.recordLoginAudit(body.Username, req.RemoteAddr, req.UserAgent())
|
||||
go r.recordLoginAudit(body.Username, clientAddress, req.UserAgent())
|
||||
}
|
||||
|
||||
func remoteHost(remoteAddress string) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress))
|
||||
if err == nil && host != "" {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(remoteAddress)
|
||||
}
|
||||
|
||||
func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
|
||||
|
||||
@@ -678,6 +678,31 @@ func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleasePackageUploadReportsInterruptedMultipart(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const boundary = "ymhut-interrupted-upload"
|
||||
body := bytes.NewBufferString("--" + boundary + "\r\n" +
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"package.exe\"\r\n" +
|
||||
"Content-Type: application/octet-stream\r\n\r\npartial package")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", body)
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "UPLOAD_INTERRUPTED") {
|
||||
t.Fatalf("interrupted upload returned %d %s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
@@ -950,7 +975,7 @@ func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
|
||||
if time.Since(started) > time.Second {
|
||||
t.Fatal("invalid login request did not return promptly")
|
||||
}
|
||||
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "LOGIN_FAILED") {
|
||||
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "CAPTCHA_INVALID") {
|
||||
t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if got := res.Header().Get("Cache-Control"); got != "no-store" {
|
||||
@@ -958,6 +983,15 @@ func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteHostRemovesEphemeralPort(t *testing.T) {
|
||||
if got := remoteHost("127.0.0.1:51842"); got != "127.0.0.1" {
|
||||
t.Fatalf("remoteHost returned %q", got)
|
||||
}
|
||||
if got := remoteHost("[::1]:51842"); got != "::1" {
|
||||
t.Fatalf("remoteHost returned %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func readTestCaptcha(dataURL string) (string, error) {
|
||||
const prefix = "data:image/png;base64,"
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
|
||||
|
||||
Reference in New Issue
Block a user