296 lines
10 KiB
Go
296 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func readAccountRequest(writer http.ResponseWriter, request *http.Request, input any) bool {
|
|
if !playbackRequestAllowed(writer, request, http.MethodPost) {
|
|
return false
|
|
}
|
|
if request.Header.Get("X-Juku-Viewer") == "" {
|
|
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请从剧库账号面板操作"})
|
|
return false
|
|
}
|
|
contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
|
if contentType != "application/json" {
|
|
writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "账号请求必须使用 JSON"})
|
|
return false
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 4096))
|
|
decoder.DisallowUnknownFields()
|
|
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
|
|
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "账号请求无效"})
|
|
return false
|
|
}
|
|
return requestViewer(writer, request) != nil
|
|
}
|
|
|
|
func accountOperationError(writer http.ResponseWriter, err error) {
|
|
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "账号操作未完成:" + publicError(err).Error()})
|
|
}
|
|
|
|
func accountHashPermit(writer http.ResponseWriter, request *http.Request, store *accountStore, name string) bool {
|
|
if store.err != nil {
|
|
accountOperationError(writer, store.err)
|
|
return false
|
|
}
|
|
if !store.allowAttempt(name, request.RemoteAddr, time.Now()) {
|
|
writer.Header().Set("Retry-After", "60")
|
|
writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "账号尝试次数较多,请一分钟后重试"})
|
|
return false
|
|
}
|
|
select {
|
|
case store.hashing <- struct{}{}:
|
|
return true
|
|
default:
|
|
writer.Header().Set("Retry-After", "2")
|
|
writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "正在处理其他登录请求,请稍后重试"})
|
|
return false
|
|
}
|
|
}
|
|
|
|
func accountSecureRequest(request *http.Request) bool {
|
|
return request.TLS != nil || strings.EqualFold(request.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|
|
|
|
func (manager *viewerManager) setAccountCookie(writer http.ResponseWriter, request *http.Request, token string) {
|
|
maxAge, expires := int(accountLifetime/time.Second), time.Now().Add(accountLifetime)
|
|
if token == "" {
|
|
maxAge, expires = -1, time.Unix(1, 0)
|
|
}
|
|
http.SetCookie(writer, &http.Cookie{Name: manager.accountCookieName(), Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
Secure: accountSecureRequest(request), MaxAge: maxAge, Expires: expires})
|
|
}
|
|
|
|
func (manager *viewerManager) setGuestCookie(writer http.ResponseWriter, request *http.Request, identity string) {
|
|
expires := time.Now().Add(viewerLifetime)
|
|
http.SetCookie(writer, &http.Cookie{Name: manager.cookie, Value: manager.token(identity, expires), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
Secure: accountSecureRequest(request), MaxAge: int(viewerLifetime / time.Second), Expires: expires})
|
|
}
|
|
|
|
func (app *UIApp) handleAccountRegister(writer http.ResponseWriter, request *http.Request) {
|
|
var input struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !readAccountRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
manager := app.browserViewers()
|
|
if account, _, _, _ := manager.requestAccount(request); account.ID != "" {
|
|
writeJSON(writer, http.StatusConflict, map[string]string{"error": "请先退出当前账号再注册"})
|
|
return
|
|
}
|
|
name, display, err := normalizeAccountUsername(input.Username)
|
|
if err == nil {
|
|
err = validateAccountPassword(input.Password)
|
|
}
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
store := manager.accountStore()
|
|
if _, allowed := store.policy(); !allowed {
|
|
writeJSON(writer, http.StatusForbidden, map[string]string{"code": "registration_disabled", "error": "管理员已关闭注册,请联系管理员创建账号"})
|
|
return
|
|
}
|
|
if !accountHashPermit(writer, request, store, name) {
|
|
return
|
|
}
|
|
defer func() { <-store.hashing }()
|
|
salt, hash, err := newAccountPassword(input.Password)
|
|
if err != nil {
|
|
accountOperationError(writer, err)
|
|
return
|
|
}
|
|
identifier := randomHex(32)
|
|
if identifier == "" {
|
|
accountOperationError(writer, errors.New("无法创建账号,请重试"))
|
|
return
|
|
}
|
|
now := time.Now()
|
|
account := accountRecord{ID: identifier, Username: display, Salt: salt, PasswordHash: hash, Algorithm: "pbkdf2-sha256", Iterations: accountPasswordIterations, CreatedAt: now}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
if !store.state.AllowRegistration {
|
|
writeJSON(writer, http.StatusForbidden, map[string]string{"code": "registration_disabled", "error": "管理员已关闭注册,请联系管理员创建账号"})
|
|
return
|
|
}
|
|
if _, exists := store.state.Accounts[name]; exists {
|
|
writeJSON(writer, http.StatusConflict, map[string]string{"error": "该用户名已被使用"})
|
|
return
|
|
}
|
|
if len(store.state.Accounts) >= accountLimit {
|
|
writeJSON(writer, http.StatusConflict, map[string]string{"error": "此服务的账号数量已达上限"})
|
|
return
|
|
}
|
|
state := store.snapshotLocked()
|
|
state.Accounts[name] = account
|
|
token, err := state.addSession(name, now)
|
|
if err == nil {
|
|
err = store.saveLocked(state)
|
|
}
|
|
if err != nil {
|
|
accountOperationError(writer, err)
|
|
return
|
|
}
|
|
manager.setAccountCookie(writer, request, token)
|
|
writeJSON(writer, http.StatusCreated, map[string]any{"ok": true, "account": account.public()})
|
|
}
|
|
|
|
func (app *UIApp) handleAccountLogin(writer http.ResponseWriter, request *http.Request) {
|
|
var input struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !readAccountRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
manager := app.browserViewers()
|
|
if account, _, _, _ := manager.requestAccount(request); account.ID != "" {
|
|
writeJSON(writer, http.StatusConflict, map[string]string{"error": "请先退出当前账号再切换账号"})
|
|
return
|
|
}
|
|
name, _, nameErr := normalizeAccountUsername(input.Username)
|
|
if nameErr != nil || validateAccountPassword(input.Password) != nil {
|
|
writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "用户名或密码不正确"})
|
|
return
|
|
}
|
|
store := manager.accountStore()
|
|
if !accountHashPermit(writer, request, store, name) {
|
|
return
|
|
}
|
|
defer func() { <-store.hashing }()
|
|
store.mu.Lock()
|
|
account, found := store.state.Accounts[name]
|
|
store.mu.Unlock()
|
|
if !found {
|
|
dummySalt := manager.signature("account-password-dummy")
|
|
account = accountRecord{Salt: hex.EncodeToString(dummySalt[:16]), PasswordHash: strings.Repeat("0", 64), Iterations: accountPasswordIterations}
|
|
}
|
|
valid := verifyAccountPassword(account, input.Password)
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
current := store.state.Accounts[name]
|
|
if !valid || !found || current.ID != account.ID || current.PasswordHash != account.PasswordHash || current.Salt != account.Salt {
|
|
writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "用户名或密码不正确"})
|
|
return
|
|
}
|
|
state := store.snapshotLocked()
|
|
token, err := state.addSession(name, time.Now())
|
|
if err == nil {
|
|
err = store.saveLocked(state)
|
|
}
|
|
if err != nil {
|
|
accountOperationError(writer, err)
|
|
return
|
|
}
|
|
manager.setAccountCookie(writer, request, token)
|
|
writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "account": account.public()})
|
|
}
|
|
|
|
func (app *UIApp) handleAccountLogout(writer http.ResponseWriter, request *http.Request) {
|
|
var input struct{}
|
|
if !readAccountRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
manager := app.browserViewers()
|
|
guest := randomHex(32)
|
|
if guest == "" {
|
|
accountOperationError(writer, errors.New("无法建立新的浏览器身份,请重试"))
|
|
return
|
|
}
|
|
if cookie, err := request.Cookie(manager.accountCookieName()); err == nil {
|
|
store := manager.accountStore()
|
|
store.mu.Lock()
|
|
state := store.snapshotLocked()
|
|
delete(state.Sessions, accountTokenHash(cookie.Value))
|
|
err = store.saveLocked(state)
|
|
store.mu.Unlock()
|
|
if err != nil {
|
|
accountOperationError(writer, err)
|
|
return
|
|
}
|
|
}
|
|
manager.setAccountCookie(writer, request, "")
|
|
manager.setGuestCookie(writer, request, guest)
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (app *UIApp) handleAccountPassword(writer http.ResponseWriter, request *http.Request) {
|
|
var input struct {
|
|
Password string `json:"password"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
if !readAccountRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
manager := app.browserViewers()
|
|
account, name, _, _ := manager.requestAccount(request)
|
|
if account.ID == "" {
|
|
writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "请先登录账号"})
|
|
return
|
|
}
|
|
if account.RequirePasswordChange && input.NewPassword == input.Password {
|
|
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "新密码不能与随机初始密码相同"})
|
|
return
|
|
}
|
|
if err := validateAccountPassword(input.NewPassword); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if validateAccountPassword(input.Password) != nil {
|
|
writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "当前密码不正确"})
|
|
return
|
|
}
|
|
store := manager.accountStore()
|
|
if !accountHashPermit(writer, request, store, name) {
|
|
return
|
|
}
|
|
defer func() { <-store.hashing }()
|
|
if !verifyAccountPassword(account, input.Password) {
|
|
writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "当前密码不正确"})
|
|
return
|
|
}
|
|
salt, hash, err := newAccountPassword(input.NewPassword)
|
|
if err != nil {
|
|
accountOperationError(writer, err)
|
|
return
|
|
}
|
|
cookie, _ := request.Cookie(manager.accountCookieName())
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
current := store.state.Accounts[name]
|
|
if current.PasswordHash != account.PasswordHash || current.Salt != account.Salt || cookie == nil || store.state.Sessions[accountTokenHash(cookie.Value)].Account != name {
|
|
writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "账号状态已变化,请重新登录")
|
|
return
|
|
}
|
|
state := store.snapshotLocked()
|
|
account.Salt, account.PasswordHash, account.Iterations = salt, hash, accountPasswordIterations
|
|
account.RequirePasswordChange = false
|
|
state.Accounts[name] = account
|
|
for key, session := range state.Sessions {
|
|
if session.Account == name {
|
|
delete(state.Sessions, key)
|
|
}
|
|
}
|
|
token, err := state.addSession(name, time.Now())
|
|
if err == nil {
|
|
err = store.saveLocked(state)
|
|
}
|
|
if err != nil {
|
|
accountOperationError(writer, err)
|
|
return
|
|
}
|
|
manager.setAccountCookie(writer, request, token)
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|