Files
at-container-registry/pkg/appview/handlers/device.go
T

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>
`