mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Add authentication server, dev CLI, Docker multi-service setup, and cross-platform improvements
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed login.html
|
||||
var loginFS embed.FS
|
||||
|
||||
var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html"))
|
||||
|
||||
type Handlers struct {
|
||||
store *AuthStore
|
||||
tok *TokenService
|
||||
}
|
||||
|
||||
func NewHandlers(store *AuthStore, tok *TokenService) *Handlers {
|
||||
return &Handlers{store: store, tok: tok}
|
||||
}
|
||||
|
||||
type loginPageData struct {
|
||||
RedirectURI string
|
||||
State string
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
func isOAuthPath(path string) bool {
|
||||
// Match /v{N}/dialog/oauth or /v{N}.{M}/dialog/oauth
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(parts[0], "v") && parts[1] == "dialog" && parts[2] == "oauth"
|
||||
}
|
||||
|
||||
func isMePath(path string) bool {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
if p == "me" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(p, "/")
|
||||
return len(parts) == 2 && strings.HasPrefix(parts[0], "v") && parts[1] == "me"
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleOAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if isMePath(r.URL.Path) {
|
||||
h.HandleMe(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !isOAuthPath(r.URL.Path) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.oauthGet(w, r)
|
||||
case http.MethodPost:
|
||||
h.oauthPost(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) {
|
||||
data := loginPageData{
|
||||
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
||||
State: r.URL.Query().Get("state"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := loginTmpl.Execute(w, data); err != nil {
|
||||
log.Printf("render login page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
action := r.FormValue("action")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
state := r.FormValue("state")
|
||||
|
||||
renderErr := func(msg string) {
|
||||
data := loginPageData{
|
||||
RedirectURI: redirectURI,
|
||||
State: state,
|
||||
Error: msg,
|
||||
Username: username,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := loginTmpl.Execute(w, data); err != nil {
|
||||
log.Printf("render login page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
renderErr("Username and password are required.")
|
||||
return
|
||||
}
|
||||
|
||||
var user AuthUser
|
||||
var err error
|
||||
|
||||
switch action {
|
||||
case "register":
|
||||
user, err = h.store.CreateUser(username, password)
|
||||
if err == ErrUserExists {
|
||||
renderErr("Username is already taken.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("create user: %v", err)
|
||||
renderErr("Server error. Try again.")
|
||||
return
|
||||
}
|
||||
log.Printf("registered user %q (id=%d)", user.Username, user.ID)
|
||||
|
||||
case "login":
|
||||
user, err = h.store.VerifyUser(username, password)
|
||||
if err == ErrInvalidCreds {
|
||||
renderErr("Invalid username or password.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("verify user: %v", err)
|
||||
renderErr("Server error. Try again.")
|
||||
return
|
||||
}
|
||||
log.Printf("authenticated user %q (id=%d)", user.Username, user.ID)
|
||||
|
||||
default:
|
||||
renderErr("Invalid action.")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.tok.Generate(user)
|
||||
if err != nil {
|
||||
log.Printf("generate token: %v", err)
|
||||
renderErr("Server error. Try again.")
|
||||
return
|
||||
}
|
||||
|
||||
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
|
||||
fragment := url.Values{}
|
||||
fragment.Set("access_token", token)
|
||||
fragment.Set("token_type", "bearer")
|
||||
fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10))
|
||||
fragment.Set("signed_request", "0."+b64)
|
||||
if state != "" {
|
||||
fragment.Set("state", state)
|
||||
}
|
||||
|
||||
target := redirectURI + "?" + fragment.Encode()
|
||||
log.Printf("redirecting to %s", target)
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if username == "" {
|
||||
json.NewEncoder(w).Encode(map[string]bool{"exists": false})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]bool{"exists": h.store.UserExists(username)})
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleMe(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("access_token")
|
||||
if token == "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
token = auth[7:]
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
http.Error(w, `{"error":{"message":"missing access_token","type":"OAuthException","code":190}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.tok.Validate(token)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"OAuthException","code":190}}`, err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": strconv.FormatInt(claims.Sub, 10),
|
||||
"name": claims.Name,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user