diff --git a/api/authentication.go b/api/authentication.go index a13279b..2ef6352 100644 --- a/api/authentication.go +++ b/api/authentication.go @@ -4,7 +4,10 @@ import ( "context" "encoding/base64" "errors" + "fmt" "net/http" + "net/url" + "strings" "sync" "time" @@ -16,6 +19,28 @@ import ( "github.com/safing/portbase/rng" ) +const ( + sessionCookieName = "Portmaster-API-Token" + sessionCookieTTL = 5 * time.Minute +) + +var ( + apiKeys = make(map[string]*AuthToken) + apiKeysLock sync.Mutex + + authFnSet = abool.New() + authFn AuthenticatorFunc + + sessions = make(map[string]*session) + sessionsLock sync.Mutex + + // ErrAPIAccessDeniedMessage should be wrapped by errors returned by + // AuthenticatorFunc in order to signify a blocked request, including a error + // message for the user. This is an empty message on purpose, as to allow the + // function to define the full text of the error shown to the user. + ErrAPIAccessDeniedMessage = errors.New("") +) + // Permission defines an API requests permission. type Permission int8 @@ -23,9 +48,9 @@ const ( // NotFound declares that the operation does not exist. NotFound Permission = -2 - // Require declares that the operation requires permission to be processed, - // but anyone can execute the operation. - Require Permission = -1 + // Dynamic declares that the operation requires permission to be processed, + // but anyone can execute the operation, as it reacts to permissions itself. + Dynamic Permission = -1 // NotSupported declares that the operation is not supported. NotSupported Permission = 0 @@ -61,25 +86,29 @@ type AuthenticatorFunc func(r *http.Request, s *http.Server) (*AuthToken, error) type AuthToken struct { Read Permission Write Permission +} +type session struct { + sync.Mutex + + token *AuthToken validUntil time.Time - validLock sync.Mutex } -// Expired returns whether the token has expired. -func (token *AuthToken) Expired() bool { - token.validLock.Lock() - defer token.validLock.Unlock() +// Expired returns whether the session has expired. +func (sess *session) Expired() bool { + sess.Lock() + defer sess.Unlock() - return time.Now().After(token.validUntil) + return time.Now().After(sess.validUntil) } -// Refresh refreshes the validity of the token with the given TTL. -func (token *AuthToken) Refresh(ttl time.Duration) { - token.validLock.Lock() - defer token.validLock.Unlock() +// Refresh refreshes the validity of the session with the given TTL. +func (sess *session) Refresh(ttl time.Duration) { + sess.Lock() + defer sess.Unlock() - token.validUntil = time.Now().Add(ttl) + sess.validUntil = time.Now().Add(ttl) } // AuthenticatedHandler defines the handler interface to specify custom @@ -90,25 +119,6 @@ type AuthenticatedHandler interface { WritePermission(*http.Request) Permission } -const ( - cookieName = "Portmaster-API-Token" - cookieTTL = 5 * time.Minute -) - -var ( - authFnSet = abool.New() - authFn AuthenticatorFunc - - authTokens = make(map[string]*AuthToken) - authTokensLock sync.Mutex - - // ErrAPIAccessDeniedMessage should be wrapped by errors returned by - // AuthenticatorFunc in order to signify a blocked request, including a error - // message for the user. This is an empty message on purpose, as to allow the - // function to define the full text of the error shown to the user. - ErrAPIAccessDeniedMessage = errors.New("") -) - // SetAuthenticator sets an authenticator function for the API endpoint. If none is set, all requests will be permitted. func SetAuthenticator(fn AuthenticatorFunc) error { if module.Online() { @@ -126,27 +136,22 @@ func SetAuthenticator(fn AuthenticatorFunc) error { func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := authenticateRequest(w, r, next) - if token != nil { - if _, apiRequest := getAPIContext(r); apiRequest != nil { - apiRequest.AuthToken = token - } - next.ServeHTTP(w, r) + if token == nil { + // Authenticator already replied. + return } + + // Add token to request and serve next handler. + if _, apiRequest := getAPIContext(r); apiRequest != nil { + apiRequest.AuthToken = token + } + next.ServeHTTP(w, r) }) } func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler http.Handler) *AuthToken { tracer := log.Tracer(r.Context()) - // Check if authenticator is set. - if !authFnSet.IsSet() { - // Return highest available permissions for the request. - return &AuthToken{ - Read: PermitSelf, - Write: PermitSelf, - } - } - // Check if request is read only. readRequest := isReadMethod(r.Method) @@ -179,7 +184,7 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h Read: PermitAnyone, Write: PermitAnyone, } - case Require: + case Dynamic: // Continue processing permissions, but treat as PermitAnyone. requiredPermission = PermitAnyone } @@ -196,40 +201,16 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h return nil } - // Check for an existing auth token. - token := checkAuthToken(r) - - // Get auth token from authenticator if none was in the request. - if token == nil { - var err error - token, err = authFn(r, server) - if err != nil { - // Check for internal error. - if !errors.Is(err, ErrAPIAccessDeniedMessage) { - tracer.Errorf("api: authenticator failed: %s", err) - http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError) - return nil - } - - // If authentication failed and we require authentication, return an - // authentication error. - if requiredPermission != PermitAnyone { - // Return authentication error. - tracer.Warningf("api: denying api access to %s", r.RemoteAddr) - http.Error(w, err.Error(), http.StatusForbidden) - return nil - } - - token = &AuthToken{ - Read: PermitAnyone, - Write: PermitAnyone, - } - } - - // Apply auth token to request. - err = applyAuthToken(w, token) - if err != nil { - tracer.Warningf("api: failed to create auth token: %s", err) + // Authenticate request. + token, handled := checkAuth(w, r, requiredPermission > PermitAnyone) + switch { + case handled: + return nil + case token == nil: + // Use default permissions. + token = &AuthToken{ + Read: PermitAnyone, + Write: PermitAnyone, } } @@ -254,11 +235,19 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h // Check permission. if requestPermission < requiredPermission { + // If the token is strictly public, return an authentication request. + if token.Read == PermitAnyone && token.Write == PermitAnyone { + w.Header().Set("WWW-Authenticate", "Bearer realm=Portmaster API") + http.Error(w, "Authorization required.", http.StatusUnauthorized) + return nil + } + + // Otherwise just inform of insufficient permissions. http.Error(w, "Insufficient permissions.", http.StatusForbidden) return nil } - tracer.Tracef("api: granted %s access to authenticated handler", r.RemoteAddr) + tracer.Tracef("api: granted %s access to protected handler", r.RemoteAddr) // Make a copy of the AuthToken in order mitigate the handler poisoning the // token, as changes would apply to future requests. @@ -268,85 +257,249 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h } } -func checkAuthToken(r *http.Request) *AuthToken { - // Get auth token from request. - c, err := r.Cookie(cookieName) +func checkAuth(w http.ResponseWriter, r *http.Request, authRequired bool) (token *AuthToken, handled bool) { + // Check for valid API key. + token = checkAPIKey(r) + if token != nil { + return token, false + } + + // Check for valid session cookie. + token = checkSessionCookie(r) + if token != nil { + return token, false + } + + // Check if an external authentication method is available. + if !authFnSet.IsSet() { + return nil, false + } + + // Authenticate externally. + token, err := authFn(r, server) + if err != nil { + // Check if the authentication process failed internally. + if !errors.Is(err, ErrAPIAccessDeniedMessage) { + log.Tracer(r.Context()).Errorf("api: authenticator failed: %s", err) + http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError) + return nil, true + } + + // Return authentication failure message if authentication is required. + if authRequired { + log.Tracer(r.Context()).Warningf("api: denying api access to %s", r.RemoteAddr) + http.Error(w, err.Error(), http.StatusForbidden) + return nil, true + } + + return nil, false + } + + // Abort if no token is returned. + if token == nil { + return nil, false + } + + // Create session cookie for authenticated request. + err = createSession(w, r, token) + if err != nil { + log.Tracer(r.Context()).Warningf("api: failed to create session: %s", err) + } + return token, false +} + +func checkAPIKey(r *http.Request) *AuthToken { + // Get API key from request. + key := r.Header.Get("Authorization") + if key == "" { + return nil + } + + // Parse API key. + switch { + case strings.HasPrefix(key, "Bearer "): + key = strings.TrimPrefix(key, "Bearer ") + case strings.HasPrefix(key, "Basic "): + user, pass, _ := r.BasicAuth() + key = user + pass + default: + log.Tracer(r.Context()).Tracef( + "api: provided api key type %s is unsupported", strings.Split(key, " ")[0], + ) + return nil + } + + apiKeysLock.Lock() + defer apiKeysLock.Unlock() + + // Check if the provided API key exists. + token, ok := apiKeys[key] + if !ok { + log.Tracer(r.Context()).Tracef( + "api: provided api key %s... is unknown", key[:4], + ) + return nil + } + + return token +} + +func updateAPIKeys(_ context.Context, _ interface{}) error { + apiKeysLock.Lock() + defer apiKeysLock.Unlock() + + log.Debug("api: importing possibly updated API keys from config") + + // Delete current keys. + for k := range apiKeys { + delete(apiKeys, k) + } + + // Parse new keys. + for _, key := range configuredAPIKeys() { + u, err := url.Parse(key) + if err != nil { + log.Errorf("api: failed to parse configured API key %s: %s", key, err) + continue + } + if u.Path == "" { + log.Errorf("api: malformed API key %s: missing path section", key) + continue + } + + // Create token with default permissions. + token := &AuthToken{ + Read: PermitAnyone, + Write: PermitAnyone, + } + + // Update with configured permissions. + q := u.Query() + // Parse read permission. + readPermission, err := parseAPIPermission(q.Get("read")) + if err != nil { + log.Errorf("api: invalid API key %s: %s", key, err) + continue + } + token.Read = readPermission + // Parse write permission. + writePermission, err := parseAPIPermission(q.Get("write")) + if err != nil { + log.Errorf("api: invalid API key %s: %s", key, err) + continue + } + token.Write = writePermission + + // Save token. + apiKeys[u.Path] = token + } + + return nil +} + +func checkSessionCookie(r *http.Request) *AuthToken { + // Get session cookie from request. + c, err := r.Cookie(sessionCookieName) if err != nil { return nil } - // Check if auth token is registered. - authTokensLock.Lock() - token, ok := authTokens[c.Value] - authTokensLock.Unlock() + // Check if session cookie is registered. + sessionsLock.Lock() + sess, ok := sessions[c.Value] + sessionsLock.Unlock() if !ok { - log.Tracer(r.Context()).Tracef("api: provided auth token %s is unknown", c.Value) + log.Tracer(r.Context()).Tracef("api: provided session cookie %s is unknown", c.Value) return nil } - // Check if token is still valid. - if token.Expired() { - log.Tracer(r.Context()).Tracef("api: provided auth token %s has expired", c.Value) + // Check if session is still valid. + if sess.Expired() { + log.Tracer(r.Context()).Tracef("api: provided session cookie %s has expired", c.Value) return nil } - // Refresh token and return. - token.Refresh(cookieTTL) - log.Tracer(r.Context()).Tracef("api: auth token %s is valid, refreshing", c.Value) - return token + // Refresh session and return. + sess.Refresh(sessionCookieTTL) + log.Tracer(r.Context()).Tracef("api: session cookie %s is valid, refreshing", c.Value) + return sess.token } -func applyAuthToken(w http.ResponseWriter, token *AuthToken) error { - // Generate new token secret. +func createSession(w http.ResponseWriter, r *http.Request, token *AuthToken) error { + // Generate new session key. secret, err := rng.Bytes(32) // 256 bit if err != nil { return err } - secretHex := base64.RawURLEncoding.EncodeToString(secret) + sessionKey := base64.RawURLEncoding.EncodeToString(secret) // Set token cookie in response. http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: secretHex, + Name: sessionCookieName, + Value: sessionKey, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, }) - // Set token TTL. - token.Refresh(cookieTTL) + // Create session. + sess := &session{ + token: token, + } + sess.Refresh(sessionCookieTTL) - // Save token. - authTokensLock.Lock() - defer authTokensLock.Unlock() - authTokens[secretHex] = token + // Save session. + sessionsLock.Lock() + defer sessionsLock.Unlock() + sessions[sessionKey] = sess + log.Tracer(r.Context()).Debug("api: issued session cookie") return nil } -func cleanAuthTokens(_ context.Context, _ *modules.Task) error { - authTokensLock.Lock() - defer authTokensLock.Unlock() +func cleanSessions(_ context.Context, _ *modules.Task) error { + sessionsLock.Lock() + defer sessionsLock.Unlock() - for secret, token := range authTokens { - if token.Expired() { - delete(authTokens, secret) + for sessionKey, sess := range sessions { + if sess.Expired() { + delete(sessions, sessionKey) } } return nil } +func deleteSession(sessionKey string) { + sessionsLock.Lock() + defer sessionsLock.Unlock() + + delete(sessions, sessionKey) +} + func isReadMethod(method string) bool { return method == http.MethodGet || method == http.MethodHead } +func parseAPIPermission(s string) (Permission, error) { + switch strings.ToLower(s) { + case "", "anyone": + return PermitAnyone, nil + case "user": + return PermitUser, nil + case "admin": + return PermitAdmin, nil + default: + return PermitAnyone, fmt.Errorf("invalid permission: %s", s) + } +} + func (p Permission) String() string { switch p { case NotSupported: return "NotSupported" - case Require: - return "Require" + case Dynamic: + return "Dynamic" case PermitAnyone: return "PermitAnyone" case PermitUser: @@ -361,3 +514,19 @@ func (p Permission) String() string { return "Unknown" } } + +// Role returns a string representation of the permission role. +func (p Permission) Role() string { + switch p { + case PermitAnyone: + return "Anyone" + case PermitUser: + return "User" + case PermitAdmin: + return "Admin" + case PermitSelf: + return "Self" + default: + return "Invalid" + } +} diff --git a/api/authentication_test.go b/api/authentication_test.go index 85d914d..0eafbdc 100644 --- a/api/authentication_test.go +++ b/api/authentication_test.go @@ -77,7 +77,7 @@ func TestPermissions(t *testing.T) { //nolint:gocognit PermitUser, PermitAdmin, PermitSelf, - Require, + Dynamic, NotFound, 100, // Test a too high value. -100, // Test a too low value. @@ -117,7 +117,7 @@ func TestPermissions(t *testing.T) { //nolint:gocognit case handlerPerm == PermitAnyone: // This is fast-tracked. There are not additional checks. expectSuccess = true - case handlerPerm == Require: + case handlerPerm == Dynamic: // This is turned into PermitAnyone in the authenticator. // But authentication is still processed and the result still gets // sanity checked! diff --git a/api/config.go b/api/config.go index 77ee884..0d2b5ea 100644 --- a/api/config.go +++ b/api/config.go @@ -10,12 +10,15 @@ import ( // Config Keys const ( CfgDefaultListenAddressKey = "core/listenAddress" + CfgAPIKeys = "core/apiKeys" ) var ( listenAddressFlag string listenAddressConfig config.StringOption defaultListenAddress string + + configuredAPIKeys config.StringArrayOption ) func init() { @@ -58,11 +61,28 @@ func registerConfig() error { } listenAddressConfig = config.GetAsString(CfgDefaultListenAddressKey, getDefaultListenAddress()) + err = config.Register(&config.Option{ + Name: "API Keys", + Key: CfgAPIKeys, + Description: "Defined API Keys.", + OptType: config.OptTypeStringArray, + ExpertiseLevel: config.ExpertiseLevelDeveloper, + ReleaseLevel: config.ReleaseLevelStable, + DefaultValue: []string{}, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: 514, + config.CategoryAnnotation: "Development", + }, + }) + if err != nil { + return err + } + configuredAPIKeys = config.GetAsStringArray(CfgAPIKeys, []string{}) + return nil } // SetDefaultAPIListenAddress sets the default listen address for the API. func SetDefaultAPIListenAddress(address string) { - defaultListenAddress = address } diff --git a/api/database.go b/api/database.go index 3188b17..d893f10 100644 --- a/api/database.go +++ b/api/database.go @@ -42,7 +42,13 @@ var ( ) func init() { - RegisterHandleFunc("/api/database/v1", startDatabaseAPI) // net/http pattern matching only this exact path + RegisterHandler("/api/database/v1", WrapInAuthHandler( + startDatabaseAPI, + // Default to admin read/write permissions until the database gets support + // for api permissions. + PermitAdmin, + PermitAdmin, + )) } // DatabaseAPI is a database API instance. @@ -93,7 +99,7 @@ func startDatabaseAPI(w http.ResponseWriter, r *http.Request) { go new.handler() go new.writer() - log.Infof("api request: init websocket %s %s", r.RemoteAddr, r.RequestURI) + log.Tracer(r.Context()).Infof("api request: init websocket %s %s", r.RemoteAddr, r.RequestURI) } func (api *DatabaseAPI) handler() { diff --git a/api/endpoints.go b/api/endpoints.go index 63d96fb..509715d 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -6,6 +6,7 @@ import ( "fmt" "io/ioutil" "net/http" + "sort" "strconv" "strings" "sync" @@ -23,13 +24,6 @@ type Endpoint struct { Read Permission Write Permission - // TODO: We _could_ expose more metadata to be able to build lists of actions - // automatically. - // Name string - // Description string - // Order int - // ExpertiseLevel config.ExpertiseLevel - // ActionFunc is for simple actions with a return message for the user. ActionFunc ActionFunc `json:"-"` @@ -45,6 +39,19 @@ type Endpoint struct { // HandlerFunc is the raw http handler. HandlerFunc http.HandlerFunc `json:"-"` + + // Documentation Metadata. + Name string + Description string + Parameters []Parameter +} + +// Parameter describes a parameterized variation of an endpoint. +type Parameter struct { + Method string + Field string + Value string + Description string } type ( @@ -141,10 +148,10 @@ func (e *Endpoint) check() error { } // Check permissions. - if e.Read < Require || e.Read > PermitSelf { + if e.Read < Dynamic || e.Read > PermitSelf { return errors.New("invalid read permission") } - if e.Write < Require || e.Write > PermitSelf { + if e.Write < Dynamic || e.Write > PermitSelf { return errors.New("invalid write permission") } @@ -183,6 +190,28 @@ func (e *Endpoint) check() error { return nil } +// ExportEndpoints exports the registered endpoints. The returned data must be +// treated as immutable. +func ExportEndpoints() []*Endpoint { + endpointsLock.RLock() + defer endpointsLock.RUnlock() + + // Copy the map into a slice. + eps := make([]*Endpoint, 0, len(endpoints)) + for _, ep := range endpoints { + eps = append(eps, ep) + } + + sort.Sort(sortByPath(eps)) + return eps +} + +type sortByPath []*Endpoint + +func (eps sortByPath) Len() int { return len(eps) } +func (eps sortByPath) Less(i, j int) bool { return eps[i].Path < eps[j].Path } +func (eps sortByPath) Swap(i, j int) { eps[i], eps[j] = eps[j], eps[i] } + type endpointHandler struct{} var _ AuthenticatedHandler = &endpointHandler{} // Compile time interface check. @@ -278,6 +307,7 @@ func (eh *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Write response. w.Header().Set("Content-Type", apiEndpoint.MimeType+"; charset=utf-8") w.Header().Set("Content-Length", strconv.Itoa(len(responseData))) + w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, err = w.Write(responseData) if err != nil { diff --git a/api/endpoints_debug.go b/api/endpoints_debug.go index f1e1078..e736fd9 100644 --- a/api/endpoints_debug.go +++ b/api/endpoints_debug.go @@ -3,6 +3,7 @@ package api import ( "bytes" "fmt" + "net/http" "os" "runtime/pprof" @@ -11,25 +12,37 @@ import ( func registerDebugEndpoints() error { if err := RegisterEndpoint(Endpoint{ - Path: "debug/stack", - Read: PermitAnyone, - DataFunc: getStack, + Path: "debug/stack", + Read: PermitAnyone, + DataFunc: getStack, + Name: "Get Goroutine Stack", + Description: "Returns the current goroutine stack.", }); err != nil { return err } if err := RegisterEndpoint(Endpoint{ - Path: "debug/stack/print", - Read: PermitAnyone, - ActionFunc: printStack, + Path: "debug/stack/print", + Read: PermitAnyone, + ActionFunc: printStack, + Name: "Print Goroutine Stack", + Description: "Prints the current goroutine stack to stdout.", }); err != nil { return err } if err := RegisterEndpoint(Endpoint{ - Path: "debug/info", - Read: PermitAnyone, - DataFunc: debugInfo, + Path: "debug/info", + Read: PermitAnyone, + DataFunc: debugInfo, + Name: "Get Debug Information", + Description: "Returns debugging information, including the version and platform info, errors, logs and the current goroutine stack.", + Parameters: []Parameter{{ + Method: http.MethodGet, + Field: "style", + Value: "github", + Description: "Specify the formatting style. The default is simple markdown formatting.", + }}, }); err != nil { return err } diff --git a/api/endpoints_meta.go b/api/endpoints_meta.go index c244885..b0f0081 100644 --- a/api/endpoints_meta.go +++ b/api/endpoints_meta.go @@ -3,30 +3,67 @@ package api import ( "encoding/json" "errors" + "net/http" ) func registerMetaEndpoints() error { if err := RegisterEndpoint(Endpoint{ - Path: "endpoints", - Read: PermitAnyone, - MimeType: MimeTypeJSON, - DataFunc: listEndpoints, + Path: "ping", + Read: PermitAnyone, + ActionFunc: ping, + Name: "Ping", + Description: "Pong.", }); err != nil { return err } if err := RegisterEndpoint(Endpoint{ - Path: "permission", - Read: Require, - StructFunc: permissions, + Path: "endpoints", + Read: PermitAnyone, + MimeType: MimeTypeJSON, + DataFunc: listEndpoints, + Name: "Export API Endpoints", + Description: "Returns a list of all registered endpoints and their metadata.", }); err != nil { return err } if err := RegisterEndpoint(Endpoint{ - Path: "ping", - Read: PermitAnyone, - ActionFunc: ping, + Path: "auth/permissions", + Read: Dynamic, + StructFunc: permissions, + Name: "View Current Permissions", + Description: "Returns the current permissions assigned to the request.", + }); err != nil { + return err + } + + if err := RegisterEndpoint(Endpoint{ + Path: "auth/bearer", + Read: Dynamic, + HandlerFunc: authBearer, + Name: "Request HTTP Bearer Auth", + Description: "Returns an HTTP Bearer Auth request, if not authenticated.", + }); err != nil { + return err + } + + if err := RegisterEndpoint(Endpoint{ + Path: "auth/basic", + Read: Dynamic, + HandlerFunc: authBasic, + Name: "Request HTTP Basic Auth", + Description: "Returns an HTTP Basic Auth request, if not authenticated.", + }); err != nil { + return err + } + + if err := RegisterEndpoint(Endpoint{ + Path: "auth/reset", + Read: PermitAnyone, + HandlerFunc: authReset, + Name: "Reset Authenticated Session", + Description: "Resets authentication status internally and in the browser.", }); err != nil { return err } @@ -35,10 +72,7 @@ func registerMetaEndpoints() error { } func listEndpoints(ar *Request) (data []byte, err error) { - endpointsLock.Lock() - defer endpointsLock.Unlock() - - data, err = json.Marshal(endpoints) + data, err = json.Marshal(ExportEndpoints()) return } @@ -48,18 +82,67 @@ func permissions(ar *Request) (i interface{}, err error) { } return struct { - Read Permission - Write Permission - ReadPermName string - WritePermName string + Read Permission + Write Permission + ReadRole string + WriteRole string }{ - Read: ar.AuthToken.Read, - Write: ar.AuthToken.Write, - ReadPermName: ar.AuthToken.Read.String(), - WritePermName: ar.AuthToken.Write.String(), + Read: ar.AuthToken.Read, + Write: ar.AuthToken.Write, + ReadRole: ar.AuthToken.Read.Role(), + WriteRole: ar.AuthToken.Write.Role(), }, nil } func ping(ar *Request) (msg string, err error) { return "Pong.", nil } + +func authBearer(w http.ResponseWriter, r *http.Request) { + // Check if authenticated by checking read permission. + ar := GetAPIRequest(r) + if ar.AuthToken.Read != PermitAnyone { + TextResponse(w, r, "Authenticated.") + return + } + + // Respond with desired authentication header. + w.Header().Set("WWW-Authenticate", "Bearer realm=Portmaster API") + http.Error(w, "Authorization required.", http.StatusUnauthorized) +} + +func authBasic(w http.ResponseWriter, r *http.Request) { + // Check if authenticated by checking read permission. + ar := GetAPIRequest(r) + if ar.AuthToken.Read != PermitAnyone { + TextResponse(w, r, "Authenticated.") + return + } + + // Respond with desired authentication header. + w.Header().Set("WWW-Authenticate", "Basic realm=Portmaster API") + http.Error(w, "Authorization required.", http.StatusUnauthorized) +} + +func authReset(w http.ResponseWriter, r *http.Request) { + // Get session cookie from request and delete session if exists. + c, err := r.Cookie(sessionCookieName) + if err == nil { + deleteSession(c.Value) + } + + // Delete session and cookie. + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + MaxAge: -1, // MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0' + }) + + // Request client to also reset all data. + w.Header().Set("Clear-Site-Data", "*") + + // Set HTTP Auth Realm without requesting authorization. + w.Header().Set("WWW-Authenticate", "None realm=Portmaster API") + + // Reply with 401 Unauthorized in order to clear HTTP Basic Auth data. + http.Error(w, "Session deleted.", http.StatusUnauthorized) +} diff --git a/api/main.go b/api/main.go index 2d7773d..78846ef 100644 --- a/api/main.go +++ b/api/main.go @@ -2,7 +2,10 @@ package api import ( "context" + "encoding/json" "errors" + "flag" + "os" "time" "github.com/safing/portbase/modules" @@ -10,6 +13,8 @@ import ( var ( module *modules.Module + + exportEndpoints bool ) // API Errors @@ -20,9 +25,15 @@ var ( func init() { module = modules.Register("api", prep, start, stop, "database", "config") + + flag.BoolVar(&exportEndpoints, "export-api-endpoints", false, "export api endpoint registry and exit") } func prep() error { + if exportEndpoints { + modules.SetCmdLineOperation(exportEndpointsCmd) + } + if getDefaultListenAddress() == "" { return errors.New("no default listen address for api available") } @@ -35,6 +46,10 @@ func prep() error { return err } + if err := registerConfigEndpoints(); err != nil { + return err + } + return registerMetaEndpoints() } @@ -42,9 +57,15 @@ func start() error { logFlagOverrides() go Serve() + _ = updateAPIKeys(module.Ctx, nil) + err := module.RegisterEventHook("config", "config change", "update API keys", updateAPIKeys) + if err != nil { + return err + } + // start api auth token cleaner if authFnSet.IsSet() { - module.NewTask("clean api auth tokens", cleanAuthTokens).Repeat(5 * time.Minute) + module.NewTask("clean api sessions", cleanSessions).Repeat(5 * time.Minute) } return nil @@ -56,3 +77,13 @@ func stop() error { } return nil } + +func exportEndpointsCmd() error { + data, err := json.MarshalIndent(ExportEndpoints(), "", " ") + if err != nil { + return err + } + + _, err = os.Stdout.Write(data) + return err +} diff --git a/api/request.go b/api/request.go index adf7690..f5fdbc5 100644 --- a/api/request.go +++ b/api/request.go @@ -1,9 +1,11 @@ package api import ( + "fmt" "net/http" "github.com/gorilla/mux" + "github.com/safing/portbase/log" ) // Request is a support struct to pool more request related information. @@ -42,3 +44,14 @@ func GetAPIRequest(r *http.Request) *Request { } return nil } + +// TextResponse writes a text response. +func TextResponse(w http.ResponseWriter, r *http.Request, text string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintln(w, text) + if err != nil { + log.Tracer(r.Context()).Warningf("api: failed to write text response: %s", err) + } +} diff --git a/api/router.go b/api/router.go index 6d3aa71..81154a5 100644 --- a/api/router.go +++ b/api/router.go @@ -92,7 +92,6 @@ func (mh *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error { // Setup context trace logging. ctx, tracer := log.AddTracer(r.Context()) - lrw := NewLoggingResponseWriter(w, r) // Add request context. apiRequest := &Request{ Request: r, @@ -100,6 +99,7 @@ func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error { ctx = context.WithValue(ctx, requestContextKey, apiRequest) // Add context back to request. r = r.WithContext(ctx) + lrw := NewLoggingResponseWriter(w, r) tracer.Tracef("api request: %s ___ %s", r.RemoteAddr, r.RequestURI) defer func() {