mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
* feat: Add app support This adds apps as a property to a workspace agent. The resource is added to the Terraform provider here: https://github.com/coder/terraform-provider-coder/pull/17 Apps will be opened in the dashboard or via the CLI with `coder open <name>`. If `command` is specified, a terminal will appear locally and in the web. If `target` is specified, the browser will open to an exposed instance of that target. * Compare fields in apps test * Update Terraform provider to use relative path * Add some basic structure for routing * chore: Remove interface from coderd and lift API surface Abstracting coderd into an interface added misdirection because the interface was never intended to be fulfilled outside of a single implementation. This lifts the abstraction, and attaches all handlers to a root struct named `*coderd.API`. * Add basic proxy logic * Add proxying based on path * Add app proxying for wildcards * Add wsconncache * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * Add workspace route proxying endpoint - Makes the workspace conn cache concurrency-safe - Reduces unnecessary open checks in `peer.Channel` - Fixes the use of a temporary context when dialing a workspace agent * Add embed errors * chore: Refactor site to improve testing It was difficult to develop this package due to the embed build tag being mandatory on the tests. The logic to test doesn't require any embedded files. * Add test for error handler * Remove unused access url * Add RBAC tests * Fix dial agent syntax * Fix linting errors * Fix gen * Fix icon required * Adjust migration number * Fix proxy error status code * Fix empty db lookup
219 lines
6.9 KiB
Go
219 lines
6.9 KiB
Go
package httpmw
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
)
|
|
|
|
// SessionTokenKey represents the name of the cookie or query parameter the API key is stored in.
|
|
const SessionTokenKey = "session_token"
|
|
|
|
type apiKeyContextKey struct{}
|
|
|
|
// APIKey returns the API key from the ExtractAPIKey handler.
|
|
func APIKey(r *http.Request) database.APIKey {
|
|
apiKey, ok := r.Context().Value(apiKeyContextKey{}).(database.APIKey)
|
|
if !ok {
|
|
panic("developer error: apikey middleware not provided")
|
|
}
|
|
return apiKey
|
|
}
|
|
|
|
// User roles are the 'subject' field of Authorize()
|
|
type userRolesKey struct{}
|
|
|
|
// AuthorizationUserRoles returns the roles used for authorization.
|
|
// Comes from the ExtractAPIKey handler.
|
|
func AuthorizationUserRoles(r *http.Request) database.GetAuthorizationUserRolesRow {
|
|
apiKey, ok := r.Context().Value(userRolesKey{}).(database.GetAuthorizationUserRolesRow)
|
|
if !ok {
|
|
panic("developer error: user roles middleware not provided")
|
|
}
|
|
return apiKey
|
|
}
|
|
|
|
// OAuth2Configs is a collection of configurations for OAuth-based authentication.
|
|
// This should be extended to support other authentication types in the future.
|
|
type OAuth2Configs struct {
|
|
Github OAuth2Config
|
|
}
|
|
|
|
// ExtractAPIKey requires authentication using a valid API key.
|
|
// It handles extending an API key if it comes close to expiry,
|
|
// updating the last used time in the database.
|
|
func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
var cookieValue string
|
|
cookie, err := r.Cookie(SessionTokenKey)
|
|
if err != nil {
|
|
cookieValue = r.URL.Query().Get(SessionTokenKey)
|
|
} else {
|
|
cookieValue = cookie.Value
|
|
}
|
|
if cookieValue == "" {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: fmt.Sprintf("Cookie %q or query parameter must be provided", SessionTokenKey),
|
|
})
|
|
return
|
|
}
|
|
parts := strings.Split(cookieValue, "-")
|
|
// APIKeys are formatted: ID-SECRET
|
|
if len(parts) != 2 {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: fmt.Sprintf("Invalid %q cookie API key format", SessionTokenKey),
|
|
})
|
|
return
|
|
}
|
|
keyID := parts[0]
|
|
keySecret := parts[1]
|
|
// Ensuring key lengths are valid.
|
|
if len(keyID) != 10 {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: fmt.Sprintf("Invalid %q cookie API key id", SessionTokenKey),
|
|
})
|
|
return
|
|
}
|
|
if len(keySecret) != 22 {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: fmt.Sprintf("Invalid %q cookie API key secret", SessionTokenKey),
|
|
})
|
|
return
|
|
}
|
|
key, err := db.GetAPIKeyByID(r.Context(), keyID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: "API key is invalid",
|
|
})
|
|
return
|
|
}
|
|
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
|
Message: "Internal error fetching API key by id",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
hashed := sha256.Sum256([]byte(keySecret))
|
|
|
|
// Checking to see if the secret is valid.
|
|
if subtle.ConstantTimeCompare(key.HashedSecret, hashed[:]) != 1 {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: "API key secret is invalid",
|
|
})
|
|
return
|
|
}
|
|
now := database.Now()
|
|
// Tracks if the API key has properties updated!
|
|
changed := false
|
|
|
|
if key.LoginType != database.LoginTypePassword {
|
|
// Check if the OAuth token is expired!
|
|
if key.OAuthExpiry.Before(now) && !key.OAuthExpiry.IsZero() {
|
|
var oauthConfig OAuth2Config
|
|
switch key.LoginType {
|
|
case database.LoginTypeGithub:
|
|
oauthConfig = oauth.Github
|
|
default:
|
|
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
|
Message: fmt.Sprintf("Unexpected authentication type %q", key.LoginType),
|
|
})
|
|
return
|
|
}
|
|
// If it is, let's refresh it from the provided config!
|
|
token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
|
|
AccessToken: key.OAuthAccessToken,
|
|
RefreshToken: key.OAuthRefreshToken,
|
|
Expiry: key.OAuthExpiry,
|
|
}).Token()
|
|
if err != nil {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: "Could not refresh expired Oauth token",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
key.OAuthAccessToken = token.AccessToken
|
|
key.OAuthRefreshToken = token.RefreshToken
|
|
key.OAuthExpiry = token.Expiry
|
|
key.ExpiresAt = token.Expiry
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
// Checking if the key is expired.
|
|
if key.ExpiresAt.Before(now) {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: fmt.Sprintf("API key expired at %q", key.ExpiresAt.String()),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Only update LastUsed once an hour to prevent database spam.
|
|
if now.Sub(key.LastUsed) > time.Hour {
|
|
key.LastUsed = now
|
|
changed = true
|
|
}
|
|
// Only update the ExpiresAt once an hour to prevent database spam.
|
|
// We extend the ExpiresAt to reduce re-authentication.
|
|
apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
|
|
if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
|
|
key.ExpiresAt = now.Add(apiKeyLifetime)
|
|
changed = true
|
|
}
|
|
if changed {
|
|
err := db.UpdateAPIKeyByID(r.Context(), database.UpdateAPIKeyByIDParams{
|
|
ID: key.ID,
|
|
LastUsed: key.LastUsed,
|
|
ExpiresAt: key.ExpiresAt,
|
|
OAuthAccessToken: key.OAuthAccessToken,
|
|
OAuthRefreshToken: key.OAuthRefreshToken,
|
|
OAuthExpiry: key.OAuthExpiry,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
|
Message: fmt.Sprintf("API key couldn't update: %s", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// If the key is valid, we also fetch the user roles and status.
|
|
// The roles are used for RBAC authorize checks, and the status
|
|
// is to block 'suspended' users from accessing the platform.
|
|
roles, err := db.GetAuthorizationUserRoles(r.Context(), key.UserID)
|
|
if err != nil {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: "Internal error fetching user's roles",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if roles.Status != database.UserStatusActive {
|
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
|
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
ctx = context.WithValue(ctx, apiKeyContextKey{}, key)
|
|
ctx = context.WithValue(ctx, userRolesKey{}, roles)
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|