mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 20:27:16 +00:00
542 lines
14 KiB
Go
542 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
)
|
|
|
|
// DeviceCodeRequest is the request to start device authorization
|
|
type DeviceCodeRequest struct {
|
|
DeviceName string `json:"device_name"`
|
|
}
|
|
|
|
// DeviceCodeResponse is the response with user and device codes
|
|
type DeviceCodeResponse struct {
|
|
DeviceCode string `json:"device_code"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURI string `json:"verification_uri"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Interval int `json:"interval"`
|
|
}
|
|
|
|
// DeviceCodeHandler handles POST /auth/device/code
|
|
type DeviceCodeHandler struct {
|
|
Store *db.DeviceStore
|
|
AppViewBaseURL string // e.g., "http://localhost:5000"
|
|
}
|
|
|
|
func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req DeviceCodeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Default device name if not provided
|
|
if req.DeviceName == "" {
|
|
req.DeviceName = "Unknown Device"
|
|
}
|
|
|
|
// Get client IP
|
|
ip := getClientIP(r)
|
|
|
|
// Get user agent
|
|
userAgent := r.UserAgent()
|
|
|
|
// Create pending authorization
|
|
pending, err := h.Store.CreatePendingAuth(req.DeviceName, ip, userAgent)
|
|
if err != nil {
|
|
http.Error(w, "failed to create authorization", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return device code info
|
|
resp := DeviceCodeResponse{
|
|
DeviceCode: pending.DeviceCode,
|
|
UserCode: pending.UserCode,
|
|
VerificationURI: h.AppViewBaseURL + "/device",
|
|
ExpiresIn: 600, // 10 minutes
|
|
Interval: 5, // Poll every 5 seconds
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// DeviceTokenRequest is the request to poll for device authorization
|
|
type DeviceTokenRequest struct {
|
|
DeviceCode string `json:"device_code"`
|
|
}
|
|
|
|
// DeviceTokenResponse is the response with device secret or error
|
|
type DeviceTokenResponse struct {
|
|
DeviceSecret string `json:"device_secret,omitempty"`
|
|
Handle string `json:"handle,omitempty"`
|
|
DID string `json:"did,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// DeviceTokenHandler handles POST /auth/device/token
|
|
type DeviceTokenHandler struct {
|
|
Store *db.DeviceStore
|
|
}
|
|
|
|
func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req DeviceTokenRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get pending authorization
|
|
pending, ok := h.Store.GetPendingByDeviceCode(req.DeviceCode)
|
|
if !ok {
|
|
resp := DeviceTokenResponse{
|
|
Error: "expired_token",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
|
|
// Check if approved
|
|
if pending.ApprovedDID == nil || *pending.ApprovedDID == "" {
|
|
// Still pending
|
|
resp := DeviceTokenResponse{
|
|
Error: "authorization_pending",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
|
|
// Approved! Get device from store to find handle
|
|
devices := h.Store.ListDevices(*pending.ApprovedDID)
|
|
|
|
var handle string
|
|
for _, d := range devices {
|
|
if d.DID == *pending.ApprovedDID {
|
|
handle = d.Handle
|
|
break
|
|
}
|
|
}
|
|
|
|
// Return device secret
|
|
resp := DeviceTokenResponse{
|
|
DeviceSecret: *pending.DeviceSecret,
|
|
Handle: handle,
|
|
DID: *pending.ApprovedDID,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// DeviceApprovalPageHandler handles GET /device
|
|
type DeviceApprovalPageHandler struct {
|
|
Store *db.DeviceStore
|
|
SessionStore *db.SessionStore
|
|
}
|
|
|
|
func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Check if user is logged in
|
|
sessionID, ok := db.GetSessionID(r)
|
|
if !ok {
|
|
// Not logged in - redirect to login with return URL
|
|
// Explicitly build the return URL with query parameters
|
|
returnTo := r.URL.Path
|
|
if r.URL.RawQuery != "" {
|
|
returnTo = r.URL.Path + "?" + r.URL.RawQuery
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "oauth_return_to",
|
|
Value: returnTo,
|
|
Path: "/",
|
|
MaxAge: 600, // 10 minutes
|
|
HttpOnly: true,
|
|
})
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
|
|
return
|
|
}
|
|
|
|
sess, ok := h.SessionStore.Get(sessionID)
|
|
if !ok {
|
|
// Invalid session - explicitly build return URL with query parameters
|
|
returnTo := r.URL.Path
|
|
if r.URL.RawQuery != "" {
|
|
returnTo = r.URL.Path + "?" + r.URL.RawQuery
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "oauth_return_to",
|
|
Value: returnTo,
|
|
Path: "/",
|
|
MaxAge: 600,
|
|
HttpOnly: true,
|
|
})
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// Get user code from query
|
|
userCode := r.URL.Query().Get("user_code")
|
|
if userCode == "" {
|
|
http.Error(w, "user_code required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get pending authorization
|
|
pending, ok := h.Store.GetPendingByUserCode(userCode)
|
|
if !ok {
|
|
h.renderError(w, "Invalid or expired authorization code")
|
|
return
|
|
}
|
|
|
|
// Check if already approved
|
|
if pending.ApprovedDID != nil && *pending.ApprovedDID != "" {
|
|
h.renderSuccess(w, pending.DeviceName)
|
|
return
|
|
}
|
|
|
|
// Render approval page
|
|
h.renderApprovalPage(w, sess.Handle, pending)
|
|
}
|
|
|
|
// DeviceApproveRequest is the request to approve a device
|
|
type DeviceApproveRequest struct {
|
|
UserCode string `json:"user_code"`
|
|
Approve bool `json:"approve"`
|
|
}
|
|
|
|
// DeviceApproveHandler handles POST /device/approve
|
|
type DeviceApproveHandler struct {
|
|
Store *db.DeviceStore
|
|
SessionStore *db.SessionStore
|
|
}
|
|
|
|
func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Check session
|
|
sessionID, ok := db.GetSessionID(r)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sess, ok := h.SessionStore.Get(sessionID)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req DeviceApproveRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !req.Approve {
|
|
// User denied
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "denied"})
|
|
return
|
|
}
|
|
|
|
// Approve the device
|
|
_, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle)
|
|
if err != nil {
|
|
fmt.Printf("ERROR [device/approve]: Failed to approve: %v\n", err)
|
|
http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "approved"})
|
|
}
|
|
|
|
// ListDevicesHandler handles GET /api/devices
|
|
type ListDevicesHandler struct {
|
|
Store *db.DeviceStore
|
|
SessionStore *db.SessionStore
|
|
}
|
|
|
|
func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Check session
|
|
sessionID, ok := db.GetSessionID(r)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sess, ok := h.SessionStore.Get(sessionID)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Get devices for this user
|
|
devices := h.Store.ListDevices(sess.DID)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(devices)
|
|
}
|
|
|
|
// RevokeDeviceHandler handles DELETE /api/devices/{id}
|
|
type RevokeDeviceHandler struct {
|
|
Store *db.DeviceStore
|
|
SessionStore *db.SessionStore
|
|
}
|
|
|
|
func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Check session
|
|
sessionID, ok := db.GetSessionID(r)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sess, ok := h.SessionStore.Get(sessionID)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Get device ID from URL
|
|
vars := mux.Vars(r)
|
|
deviceID := vars["id"]
|
|
if deviceID == "" {
|
|
http.Error(w, "device ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Revoke device
|
|
if err := h.Store.RevokeDevice(sess.DID, deviceID); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to revoke: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *db.PendingAuthorization) {
|
|
tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate))
|
|
data := struct {
|
|
Handle string
|
|
DeviceName string
|
|
UserCode string
|
|
IPAddress string
|
|
}{
|
|
Handle: handle,
|
|
DeviceName: pending.DeviceName,
|
|
UserCode: pending.UserCode,
|
|
IPAddress: pending.IPAddress,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
tmpl.Execute(w, data)
|
|
}
|
|
|
|
func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, deviceName string) {
|
|
tmpl := template.Must(template.New("success").Parse(deviceSuccessTemplate))
|
|
data := struct {
|
|
DeviceName string
|
|
}{
|
|
DeviceName: deviceName,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
tmpl.Execute(w, data)
|
|
}
|
|
|
|
func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, message string) {
|
|
tmpl := template.Must(template.New("error").Parse(deviceErrorTemplate))
|
|
data := struct {
|
|
Message string
|
|
}{
|
|
Message: message,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
tmpl.Execute(w, data)
|
|
}
|
|
|
|
func getClientIP(r *http.Request) string {
|
|
// Check X-Forwarded-For header
|
|
xff := r.Header.Get("X-Forwarded-For")
|
|
if xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
// Check X-Real-IP header
|
|
xri := r.Header.Get("X-Real-IP")
|
|
if xri != "" {
|
|
return xri
|
|
}
|
|
|
|
// Fall back to RemoteAddr
|
|
parts := strings.Split(r.RemoteAddr, ":")
|
|
if len(parts) > 0 {
|
|
return parts[0]
|
|
}
|
|
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// HTML templates
|
|
|
|
const deviceApprovalTemplate = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Authorize Device - ATCR</title>
|
|
<style>
|
|
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
.approval-box { background: #e3f2fd; border: 1px solid #90caf9; padding: 30px; border-radius: 8px; }
|
|
.user-code { font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 20px 0; color: #1976d2; }
|
|
.device-info { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
|
.device-info dt { font-weight: bold; margin-top: 10px; }
|
|
.device-info dd { margin-left: 0; color: #666; }
|
|
.actions { text-align: center; margin-top: 30px; }
|
|
button { font-size: 16px; padding: 12px 30px; margin: 0 10px; border: none; border-radius: 4px; cursor: pointer; }
|
|
.approve { background: #4caf50; color: white; }
|
|
.approve:hover { background: #45a049; }
|
|
.deny { background: #f44336; color: white; }
|
|
.deny:hover { background: #da190b; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="approval-box">
|
|
<h1>Authorize Device</h1>
|
|
<p>User: <strong>{{.Handle}}</strong></p>
|
|
|
|
<div class="user-code">{{.UserCode}}</div>
|
|
|
|
<div class="device-info">
|
|
<dl>
|
|
<dt>Device Name:</dt>
|
|
<dd>{{.DeviceName}}</dd>
|
|
<dt>IP Address:</dt>
|
|
<dd>{{.IPAddress}}</dd>
|
|
</dl>
|
|
</div>
|
|
|
|
<p><strong>Do you want to authorize this device?</strong></p>
|
|
<p>This device will be able to push and pull container images to your registry.</p>
|
|
|
|
<div class="actions">
|
|
<button class="approve" onclick="approve(true)">Approve</button>
|
|
<button class="deny" onclick="approve(false)">Deny</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function approve(approved) {
|
|
const resp = await fetch('/device/approve', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
user_code: '{{.UserCode}}',
|
|
approve: approved
|
|
})
|
|
});
|
|
|
|
if (resp.ok) {
|
|
if (approved) {
|
|
window.location.href = '/device?user_code={{.UserCode}}';
|
|
} else {
|
|
alert('Device authorization denied');
|
|
window.location.href = '/';
|
|
}
|
|
} else {
|
|
alert('Failed to process authorization');
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
const deviceSuccessTemplate = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Device Authorized - ATCR</title>
|
|
<style>
|
|
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 30px; border-radius: 8px; }
|
|
h1 { color: #155724; }
|
|
a { color: #007bff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="success">
|
|
<h1>✓ Device Authorized!</h1>
|
|
<p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
|
|
<p>You can now close this window and return to your terminal.</p>
|
|
<p><a href="/settings">View your authorized devices</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
const deviceErrorTemplate = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Authorization Error - ATCR</title>
|
|
<style>
|
|
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 30px; border-radius: 8px; }
|
|
h1 { color: #721c24; }
|
|
a { color: #007bff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error">
|
|
<h1>✗ Authorization Error</h1>
|
|
<p>{{.Message}}</p>
|
|
<p><a href="/">Return to home</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|