diff options
author | Mike Crute <mike@crute.us> | 2023-07-29 12:15:13 -0700 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2023-07-29 12:15:13 -0700 |
commit | 4e995f9e6c3adc43a361b6fa9b976d25378f1594 (patch) | |
tree | 862642149583fa4ad662edfe0b31a7d65b8e302e /app | |
parent | fea07831eadd35532055ec16fc43b0cde56a54b1 (diff) | |
download | websocket_proxy-4e995f9e6c3adc43a361b6fa9b976d25378f1594.tar.bz2 websocket_proxy-4e995f9e6c3adc43a361b6fa9b976d25378f1594.tar.xz websocket_proxy-4e995f9e6c3adc43a361b6fa9b976d25378f1594.zip |
Initial import of rewrite
Diffstat (limited to 'app')
-rw-r--r-- | app/config.go | 45 | ||||
-rw-r--r-- | app/controllers/ca.go | 172 | ||||
-rw-r--r-- | app/controllers/login.go | 117 | ||||
-rw-r--r-- | app/controllers/oauth2_device.go | 129 | ||||
-rw-r--r-- | app/controllers/proxy.go | 78 | ||||
-rw-r--r-- | app/controllers/register.go | 78 | ||||
-rw-r--r-- | app/middleware/token_auth.go | 76 | ||||
-rw-r--r-- | app/models/auth_session.go | 75 | ||||
-rw-r--r-- | app/models/auth_session_mongodb.go | 45 | ||||
-rw-r--r-- | app/models/auth_session_util.go | 25 | ||||
-rw-r--r-- | app/models/oauth2.go | 103 | ||||
-rw-r--r-- | app/models/oauth_client.go | 27 | ||||
-rw-r--r-- | app/models/user.go | 63 | ||||
-rw-r--r-- | app/session.go | 46 | ||||
-rw-r--r-- | app/templates.go | 18 |
15 files changed, 1097 insertions, 0 deletions
diff --git a/app/config.go b/app/config.go new file mode 100644 index 0000000..2ffd0cb --- /dev/null +++ b/app/config.go | |||
@@ -0,0 +1,45 @@ | |||
1 | package app | ||
2 | |||
3 | import "time" | ||
4 | |||
5 | type Config struct { | ||
6 | Bind []string `flag:"bind" flag-scope:"web" flag-help:"Addresses and ports to bind http server"` | ||
7 | Debug bool `flag:"debug" flag-help:"Enable debug mode"` | ||
8 | MongoDbUri string `flag:"mongodb-uri" flag-scope:"web,register" flag-help:"URI for connection to mongodb"` | ||
9 | DisableBackgroundJobs bool `flag:"disable-bg-jobs" flag-scope:"web" flag-help:"Disable background jobs and only serve web pages"` | ||
10 | Hostnames []string `flag:"hostname" flag-scope:"web" flag-help:"Hostname this server serves (can be specified multiple times)"` | ||
11 | TrustedIPRanges []string `flag:"trusted-ip-ranges" flag-scope:"web" flag-help:"Comma separated list of IP ranges for trusted XFF proxies"` | ||
12 | DNSApiKeyVaultPath string `flag:"dns-api-vault-path" flag-scope:"web" flag-help:"Vault material for DNS API key"` | ||
13 | AutocertEmail string `flag:"autocert-email" flag-scope:"web" flag-help:"Autocert notification email"` | ||
14 | AutocertHost string `flag:"autocert-host" flag-scope:"web" flag-help:"Autocert service url"` | ||
15 | NetboxHost string `flag:"netbox-host" flag-scope:"web" flag-help:"Netbox service url"` | ||
16 | NetboxApiKeyVaultPath string `flag:"netbox-api-vault-path" flag-scope:"web" flag-help:"Vault material path for Netbox API key"` | ||
17 | CookieKeyPath string `flag:"cookie-key-path" flag-scope:"web" flag-help:"Vault material path for cookie encryption key"` | ||
18 | SSHCAKeyPath string `flag:"ssh-ca-key-path" flag-scope:"web" flag-help:"Vault material path for SSH CA key"` | ||
19 | SSHCertificateExpiration time.Duration `flag:"ssh-cert-expire" flag-scope:"web" flag-help:"Lifetime duration of signed SSH certificates"` | ||
20 | OauthRPName string `flag:"oauth-rp-name" flag-scope:"web" flag-help:"Name of Oauth2 relying party for auth"` | ||
21 | OauthDevicePollSecs int `flag:"oauth-device-poll-secs" flag-scope:"web" flag-help:"Number of seconds between polls for oauth device flow"` | ||
22 | OauthSessionTimeout time.Duration `flag:"oauth-session-timelut" flag-scope:"web" flag-help:"Timeout before oauth session expires"` | ||
23 | InviteTimeout time.Duration `flag:"invite-timeout" flag-scope:"register" flag-help:"Timeout before inivitation code expires"` | ||
24 | } | ||
25 | |||
26 | var DefaultConfig = &Config{ | ||
27 | Bind: []string{":8069"}, | ||
28 | Debug: false, | ||
29 | MongoDbUri: "ssh-proxy-prod@mongodb.sea4.crute.me/ssh-proxy-prod", | ||
30 | DisableBackgroundJobs: false, | ||
31 | Hostnames: []string{"ssh-proxy.crute.me"}, | ||
32 | TrustedIPRanges: []string{"172.19.0.0/22", "2602:803:4072::/48"}, | ||
33 | DNSApiKeyVaultPath: "service/ssh-proxy/dns-api-key", | ||
34 | AutocertEmail: "letsencrypt-certs@pomonaconsulting.com", | ||
35 | AutocertHost: "https://dns-manage.crute.me/acmev2", | ||
36 | NetboxHost: "https://netbox.crute.me", | ||
37 | NetboxApiKeyVaultPath: "infra/netbox-readonly", | ||
38 | CookieKeyPath: "service/ssh-proxy/cookie-key", | ||
39 | SSHCAKeyPath: "service/ssh-proxy/ssh-ca-key", | ||
40 | SSHCertificateExpiration: time.Minute, | ||
41 | OauthRPName: "Crute SSH Proxy", | ||
42 | OauthDevicePollSecs: 5, | ||
43 | OauthSessionTimeout: 5 * time.Minute, | ||
44 | InviteTimeout: 1 * time.Hour, | ||
45 | } | ||
diff --git a/app/controllers/ca.go b/app/controllers/ca.go new file mode 100644 index 0000000..632db50 --- /dev/null +++ b/app/controllers/ca.go | |||
@@ -0,0 +1,172 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "crypto/rand" | ||
5 | "fmt" | ||
6 | "io" | ||
7 | "net/http" | ||
8 | "strings" | ||
9 | "time" | ||
10 | |||
11 | "code.crute.us/mcrute/ssh-proxy/app/middleware" | ||
12 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
13 | "github.com/labstack/echo/v4" | ||
14 | "golang.org/x/crypto/ssh" | ||
15 | ) | ||
16 | |||
17 | type CASecret struct { | ||
18 | Key string `mapstructure:"key"` | ||
19 | } | ||
20 | |||
21 | type CAHandlerConfig struct { | ||
22 | Logger echo.Logger | ||
23 | Users models.UserStore | ||
24 | Expiration time.Duration | ||
25 | Secret CASecret | ||
26 | } | ||
27 | |||
28 | type CAHandler struct { | ||
29 | Logger echo.Logger | ||
30 | Users models.UserStore | ||
31 | Expiration time.Duration | ||
32 | signer ssh.Signer | ||
33 | } | ||
34 | |||
35 | func NewCAHandler(cfg CAHandlerConfig) (*CAHandler, error) { | ||
36 | signer, err := ssh.ParsePrivateKey([]byte(cfg.Secret.Key)) | ||
37 | if err != nil { | ||
38 | return nil, err | ||
39 | } | ||
40 | |||
41 | cfg.Logger.Infof("CA Authorized Key: %s", ssh.MarshalAuthorizedKey(signer.PublicKey())) | ||
42 | |||
43 | return &CAHandler{ | ||
44 | Logger: cfg.Logger, | ||
45 | Users: cfg.Users, | ||
46 | Expiration: cfg.Expiration, | ||
47 | signer: signer, | ||
48 | }, nil | ||
49 | } | ||
50 | |||
51 | func (h *CAHandler) authorizeRequest(c echo.Context, certRequest *ssh.Certificate) error { | ||
52 | session := middleware.GetAuthorizedSession(c) | ||
53 | |||
54 | user, err := h.Users.Get(c.Request().Context(), session.UserId) | ||
55 | if err != nil { | ||
56 | return err | ||
57 | } | ||
58 | |||
59 | if user.Username != certRequest.ValidPrincipals[0] { | ||
60 | return fmt.Errorf("Authenticated username and cert username must match") | ||
61 | } | ||
62 | |||
63 | if !session.HasScope("ca:issue") { | ||
64 | return fmt.Errorf("Authorized session does not have scope ca:issue") | ||
65 | } | ||
66 | |||
67 | if certRequest.Extensions == nil { | ||
68 | return fmt.Errorf("Cert request extensions are empty") | ||
69 | } | ||
70 | |||
71 | hostLine, ok := certRequest.Extensions["allowed-hosts"] | ||
72 | if !ok { | ||
73 | return fmt.Errorf("Cert request allowed-hosts is blank") | ||
74 | } | ||
75 | |||
76 | for _, host := range strings.Split(hostLine, ",") { | ||
77 | if !user.AuthorizedForHost(host) { | ||
78 | return fmt.Errorf("User %s is not authorized for host %s", session.UserId, host) | ||
79 | } | ||
80 | } | ||
81 | |||
82 | h.Logger.Infof("Allowing user %s to obtain SSH certificate for hosts %s", user.Username, hostLine) | ||
83 | return nil | ||
84 | } | ||
85 | |||
86 | func (h *CAHandler) verifyRequestSignature(c *ssh.Certificate) error { | ||
87 | // Copied from ssh.Certificate#bytesForSigning | ||
88 | // https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.11.0:ssh/certs.go;l=499-505 | ||
89 | c2 := *c | ||
90 | c2.Signature = nil | ||
91 | out := c2.Marshal() | ||
92 | // Drop trailing signature length. | ||
93 | return c.Verify(out[:len(out)-4], c.Signature) | ||
94 | } | ||
95 | |||
96 | func (h *CAHandler) HandleIssue(c echo.Context) error { | ||
97 | req, err := io.ReadAll(c.Request().Body) | ||
98 | if err != nil { | ||
99 | return c.JSON(http.StatusBadRequest, map[string]string{ | ||
100 | "error": "Unable to read request body", | ||
101 | }) | ||
102 | } | ||
103 | |||
104 | pubkey, _, _, _, err := ssh.ParseAuthorizedKey(req) | ||
105 | if err != nil { | ||
106 | return c.JSON(http.StatusBadRequest, map[string]string{ | ||
107 | "error": "Error parsing certificate request", | ||
108 | }) | ||
109 | } | ||
110 | |||
111 | certRequest, ok := pubkey.(*ssh.Certificate) | ||
112 | if !ok { | ||
113 | return c.JSON(http.StatusBadRequest, map[string]string{ | ||
114 | "error": "Invalid format for certificate request", | ||
115 | }) | ||
116 | } | ||
117 | |||
118 | if certRequest.CertType != ssh.UserCert { | ||
119 | return c.JSON(http.StatusBadRequest, map[string]string{ | ||
120 | "error": "This CA only issues user certificates", | ||
121 | }) | ||
122 | } | ||
123 | |||
124 | if len(certRequest.ValidPrincipals) != 1 { | ||
125 | return c.JSON(http.StatusBadRequest, map[string]string{ | ||
126 | "error": "Invalid number of principals specified", | ||
127 | }) | ||
128 | } | ||
129 | |||
130 | // Kinda silly I guess but at least proves that the requestor | ||
131 | // is in posession of the private key that we're signing | ||
132 | if err := h.verifyRequestSignature(certRequest); err != nil { | ||
133 | h.Logger.Error(err) | ||
134 | return c.JSON(http.StatusUnauthorized, map[string]string{ | ||
135 | "error": "Invalid signature", | ||
136 | }) | ||
137 | } | ||
138 | |||
139 | if err := h.authorizeRequest(c, certRequest); err != nil { | ||
140 | h.Logger.Error(err) | ||
141 | return c.JSON(http.StatusUnauthorized, map[string]string{ | ||
142 | "error": "Not authorized", | ||
143 | }) | ||
144 | } | ||
145 | |||
146 | utcNow := time.Now().UTC() | ||
147 | |||
148 | // Serial doesn't really matter since these are so short lived and we | ||
149 | // won't be revoking them | ||
150 | certToIssue := &ssh.Certificate{ | ||
151 | Key: certRequest.Key, | ||
152 | Serial: uint64(utcNow.Unix()), | ||
153 | CertType: ssh.UserCert, | ||
154 | KeyId: fmt.Sprintf("%s_%d", certRequest.ValidPrincipals[0], utcNow.Unix()), | ||
155 | ValidPrincipals: certRequest.ValidPrincipals, | ||
156 | ValidAfter: uint64(utcNow.Add(-5 * time.Minute).Unix()), | ||
157 | ValidBefore: uint64(utcNow.Add(h.Expiration).Unix()), | ||
158 | Permissions: ssh.Permissions{ | ||
159 | Extensions: map[string]string{ | ||
160 | "permit-pty": "", | ||
161 | }, | ||
162 | }, | ||
163 | } | ||
164 | |||
165 | if err := certToIssue.SignCert(rand.Reader, h.signer); err != nil { | ||
166 | return c.JSON(http.StatusBadRequest, map[string]string{ | ||
167 | "error": "Error signing certificate", | ||
168 | }) | ||
169 | } | ||
170 | |||
171 | return c.Blob(http.StatusOK, "application/x-ssh-certificate", ssh.MarshalAuthorizedKey(certToIssue)) | ||
172 | } | ||
diff --git a/app/controllers/login.go b/app/controllers/login.go new file mode 100644 index 0000000..603eb20 --- /dev/null +++ b/app/controllers/login.go | |||
@@ -0,0 +1,117 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "bytes" | ||
5 | "encoding/json" | ||
6 | "io" | ||
7 | "net/http" | ||
8 | "time" | ||
9 | |||
10 | "code.crute.us/mcrute/golib/echo/session" | ||
11 | "code.crute.us/mcrute/ssh-proxy/app" | ||
12 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
13 | "github.com/go-webauthn/webauthn/protocol" | ||
14 | "github.com/go-webauthn/webauthn/webauthn" | ||
15 | "github.com/labstack/echo/v4" | ||
16 | ) | ||
17 | |||
18 | type LoginController[T app.AppSession] struct { | ||
19 | Logger echo.Logger | ||
20 | Sessions session.Store[T] | ||
21 | Users models.UserStore | ||
22 | AuthSessions models.AuthSessionStore | ||
23 | Webauthn *webauthn.WebAuthn | ||
24 | SessionExpiration time.Duration | ||
25 | } | ||
26 | |||
27 | func (a *LoginController[T]) HandleStart(c echo.Context) error { | ||
28 | user, err := a.Users.Get(c.Request().Context(), c.Param("username")) | ||
29 | if err != nil { | ||
30 | a.Logger.Errorf("Error getting user: %s", err) | ||
31 | return c.NoContent(http.StatusNotFound) | ||
32 | } | ||
33 | |||
34 | request, sessionData, err := a.Webauthn.BeginLogin(user) | ||
35 | if err != nil { | ||
36 | a.Logger.Errorf("Error creating webauthn request: %s", err) | ||
37 | return c.NoContent(http.StatusInternalServerError) | ||
38 | } | ||
39 | |||
40 | session := a.Sessions.Get(c) | ||
41 | s := session.Self() | ||
42 | s.WebauthnSession = sessionData | ||
43 | a.Sessions.Update(c, session) | ||
44 | |||
45 | return c.JSON(http.StatusOK, request) | ||
46 | } | ||
47 | |||
48 | func (a *LoginController[T]) HandleFinish(c echo.Context) error { | ||
49 | ctx := c.Request().Context() | ||
50 | |||
51 | body, err := io.ReadAll(c.Request().Body) | ||
52 | if err != nil { | ||
53 | a.Logger.Errorf("Error reading request body:", err) | ||
54 | return c.NoContent(http.StatusInternalServerError) | ||
55 | } | ||
56 | |||
57 | user, err := a.Users.Get(ctx, c.Param("username")) | ||
58 | if err != nil { | ||
59 | a.Logger.Errorf("Error getting user: %s", err) | ||
60 | return c.NoContent(http.StatusNotFound) | ||
61 | } | ||
62 | |||
63 | response, err := protocol.ParseCredentialRequestResponseBody(bytes.NewBuffer(body)) | ||
64 | if err != nil { | ||
65 | a.Logger.Errorf("Error parsing credential response: %s", err) | ||
66 | return c.NoContent(http.StatusBadRequest) | ||
67 | } | ||
68 | |||
69 | session := a.Sessions.Get(c) | ||
70 | s := session.Self() | ||
71 | |||
72 | if s.WebauthnSession == nil { | ||
73 | a.Logger.Errorf("Webauthn session is not set") | ||
74 | return c.NoContent(http.StatusBadRequest) | ||
75 | } | ||
76 | |||
77 | if _, err := a.Webauthn.ValidateLogin(user, *s.WebauthnSession, response); err != nil { | ||
78 | a.Logger.Errorf("Error validating login: %s", err) | ||
79 | return c.NoContent(http.StatusBadRequest) | ||
80 | } | ||
81 | |||
82 | // Don't check the clone warning or the auth count because these are | ||
83 | // meaningless for Passkeys since they are synced across devices | ||
84 | // (presumably securely). This would only matter for hard tokens like | ||
85 | // Yubikeys and since we're also allowing Passkey support there is no | ||
86 | // need to be more strict for that class of device. | ||
87 | |||
88 | var code struct { | ||
89 | Code string `json:"code"` | ||
90 | } | ||
91 | if err := json.Unmarshal(body, &code); err != nil { | ||
92 | a.Logger.Errorf("Error decoding json body") | ||
93 | return c.NoContent(http.StatusBadRequest) | ||
94 | } | ||
95 | |||
96 | authSession, err := a.AuthSessions.GetByUserCode(ctx, code.Code) | ||
97 | if err != nil { | ||
98 | a.Logger.Errorf("No auth session exists") | ||
99 | return c.NoContent(http.StatusUnauthorized) | ||
100 | } | ||
101 | |||
102 | if authSession.AccessCode != "" { | ||
103 | a.Logger.Errorf("Session is already authenticated") | ||
104 | return c.NoContent(http.StatusUnauthorized) | ||
105 | } | ||
106 | |||
107 | authSession.GenerateAccessCode() | ||
108 | authSession.UserId = user.Username | ||
109 | authSession.Expires = time.Now().Add(a.SessionExpiration) | ||
110 | |||
111 | if err := a.AuthSessions.Upsert(ctx, authSession); err != nil { | ||
112 | a.Logger.Errorf("Error saving auth session") | ||
113 | return c.NoContent(http.StatusInternalServerError) | ||
114 | } | ||
115 | |||
116 | return c.NoContent(http.StatusOK) | ||
117 | } | ||
diff --git a/app/controllers/oauth2_device.go b/app/controllers/oauth2_device.go new file mode 100644 index 0000000..0ddf653 --- /dev/null +++ b/app/controllers/oauth2_device.go | |||
@@ -0,0 +1,129 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "crypto/subtle" | ||
5 | "fmt" | ||
6 | "net/http" | ||
7 | "strconv" | ||
8 | "time" | ||
9 | |||
10 | "code.crute.us/mcrute/ssh-proxy/app" | ||
11 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
12 | "github.com/labstack/echo/v4" | ||
13 | ) | ||
14 | |||
15 | func badRequest(c echo.Context, e models.AuthorizationError, d string) error { | ||
16 | return c.JSON(http.StatusBadRequest, models.Oauth2Error{ | ||
17 | Type: e, | ||
18 | Description: d, | ||
19 | }) | ||
20 | } | ||
21 | |||
22 | type OAuth2DeviceController[T app.AppSession] struct { | ||
23 | Logger echo.Logger | ||
24 | OauthClients models.OauthClientStore | ||
25 | AuthSessions models.AuthSessionStore | ||
26 | Hostname string | ||
27 | PollSeconds int | ||
28 | SessionExpiration time.Duration | ||
29 | } | ||
30 | |||
31 | func (a *OAuth2DeviceController[T]) HandleStart(c echo.Context) error { | ||
32 | ctx := c.Request().Context() | ||
33 | |||
34 | var form models.AuthorizationRequest | ||
35 | if err := (&echo.DefaultBinder{}).BindBody(c, &form); err != nil { | ||
36 | a.Logger.Errorf("Unable to parse form data: %s", err) | ||
37 | return badRequest(c, models.ErrInvalidRequest, "") | ||
38 | } | ||
39 | |||
40 | client, err := a.OauthClients.Get(ctx, form.ClientId) | ||
41 | if err != nil { | ||
42 | a.Logger.Errorf("Unable to find client ID '%s': %s", form.ClientId, err) | ||
43 | return badRequest(c, models.ErrUnauthorizedClient, "") | ||
44 | } | ||
45 | |||
46 | if len(form.Challenge) <= 16 { | ||
47 | return badRequest(c, models.ErrInvalidRequest, | ||
48 | "code_challenge is too short, minimum length is 16 bytes") | ||
49 | } | ||
50 | |||
51 | if form.ChallengeMethod != models.ChallengeS256 { | ||
52 | return badRequest(c, models.ErrInvalidRequest, | ||
53 | "code_challenge_method invalid, only S256 supported") | ||
54 | } | ||
55 | |||
56 | session := models.NewAuthSession(client.Id, time.Now().Add(a.SessionExpiration)) | ||
57 | session.SetChallenge(form.Challenge, form.ChallengeMethod) | ||
58 | session.SetScopeString(form.Scope) | ||
59 | |||
60 | if !session.HasAnyScopes() { | ||
61 | return badRequest(c, models.ErrInvalidRequest, "one or more scopes required") | ||
62 | } | ||
63 | |||
64 | for _, s := range session.Scope { | ||
65 | if s != "ssh:proxy" && s != "ca:issue" { | ||
66 | return badRequest(c, models.ErrInvalidScope, fmt.Sprintf("scope %s is not recognized", s)) | ||
67 | } | ||
68 | } | ||
69 | |||
70 | if err := a.AuthSessions.Insert(ctx, session); err != nil { | ||
71 | a.Logger.Errorf("Error inserting auth session", err) | ||
72 | return c.NoContent(http.StatusInternalServerError) | ||
73 | } | ||
74 | |||
75 | return c.JSON(http.StatusOK, models.DeviceAuthorizationResponse{ | ||
76 | DeviceCode: session.DeviceCode, | ||
77 | UserCode: session.UserCode, | ||
78 | VerificationUri: fmt.Sprintf("%s/login", a.Hostname), | ||
79 | VerificationUriComplete: fmt.Sprintf("%s/login?code=%s", a.Hostname, session.UserCode), | ||
80 | ExpiresIn: int(time.Until(session.Expires).Seconds()), | ||
81 | Interval: a.PollSeconds, | ||
82 | }) | ||
83 | } | ||
84 | |||
85 | func (a *OAuth2DeviceController[T]) HandleToken(c echo.Context) error { | ||
86 | ctx := c.Request().Context() | ||
87 | |||
88 | var form models.DeviceAccessTokenRequest | ||
89 | if err := (&echo.DefaultBinder{}).BindBody(c, &form); err != nil { | ||
90 | a.Logger.Errorf("Unable to parse form data: %s", err) | ||
91 | return badRequest(c, models.ErrInvalidRequest, "") | ||
92 | } | ||
93 | |||
94 | session, err := a.AuthSessions.Get(ctx, form.DeviceCode) | ||
95 | if err != nil { | ||
96 | return c.NoContent(http.StatusNotFound) | ||
97 | } | ||
98 | |||
99 | if form.GrantType != models.DEVICE_CODE_GRANT_TYPE { | ||
100 | return badRequest(c, models.ErrUnsupportedGrantType, "") | ||
101 | } | ||
102 | |||
103 | if subtle.ConstantTimeCompare([]byte(session.ClientId), []byte(form.ClientId)) != 1 { | ||
104 | return badRequest(c, models.ErrUnauthorizedClient, "") | ||
105 | } | ||
106 | |||
107 | if time.Now().After(session.Expires) { | ||
108 | return badRequest(c, models.ErrExpiredToken, "") | ||
109 | } | ||
110 | |||
111 | verifier := &models.PKCEChallenge{Verifier: form.CodeVerifier} | ||
112 | if verifier.EqualString(session.Challenge) { | ||
113 | return badRequest(c, models.ErrInvalidGrant, "") // Per RFC7636 4.6 | ||
114 | } | ||
115 | |||
116 | if session.IsRegistration { | ||
117 | return badRequest(c, models.ErrInvalidGrant, "") | ||
118 | } | ||
119 | |||
120 | if session.AccessCode == "" { | ||
121 | return badRequest(c, models.ErrAuthorizationPending, "") | ||
122 | } | ||
123 | |||
124 | return c.JSON(http.StatusOK, models.AccessTokenResponse{ | ||
125 | AccessToken: session.AccessCode, | ||
126 | TokenType: "Bearer", | ||
127 | ExpiresIn: strconv.FormatInt(int64(time.Until(session.Expires).Seconds()), 10), | ||
128 | }) | ||
129 | } | ||
diff --git a/app/controllers/proxy.go b/app/controllers/proxy.go new file mode 100644 index 0000000..c8345e8 --- /dev/null +++ b/app/controllers/proxy.go | |||
@@ -0,0 +1,78 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "fmt" | ||
5 | "net" | ||
6 | "net/http" | ||
7 | "strconv" | ||
8 | |||
9 | "code.crute.us/mcrute/ssh-proxy/app/middleware" | ||
10 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
11 | "code.crute.us/mcrute/ssh-proxy/proxy" | ||
12 | |||
13 | "github.com/gorilla/websocket" | ||
14 | "github.com/labstack/echo/v4" | ||
15 | ) | ||
16 | |||
17 | type ProxyHandler struct { | ||
18 | Logger echo.Logger | ||
19 | Upgrader websocket.Upgrader | ||
20 | Users models.UserStore | ||
21 | } | ||
22 | |||
23 | func getConnectAddr(c echo.Context) string { | ||
24 | p, err := strconv.Atoi(c.Param("port")) | ||
25 | if err != nil { | ||
26 | p = 22 | ||
27 | } | ||
28 | return fmt.Sprintf("%s:%d", c.Param("host"), p) | ||
29 | } | ||
30 | |||
31 | func (h *ProxyHandler) authorizeRequest(c echo.Context) error { | ||
32 | session := middleware.GetAuthorizedSession(c) | ||
33 | |||
34 | user, err := h.Users.Get(c.Request().Context(), session.UserId) | ||
35 | if err != nil { | ||
36 | return err | ||
37 | } | ||
38 | |||
39 | if !session.HasScope("ssh:proxy") { | ||
40 | return fmt.Errorf("Authorized session does not have scope ssh:proxy") | ||
41 | } | ||
42 | |||
43 | host := c.Param("host") | ||
44 | if user.AuthorizedForHost(host) { | ||
45 | h.Logger.Infof("Allowing user %s to proxy to host %s", session.UserId, host) | ||
46 | return nil | ||
47 | } | ||
48 | |||
49 | return fmt.Errorf("User %s not authorized for host %s", session.UserId, host) | ||
50 | } | ||
51 | |||
52 | func (h *ProxyHandler) Handle(c echo.Context) error { | ||
53 | if err := h.authorizeRequest(c); err != nil { | ||
54 | h.Logger.Error(err) | ||
55 | return c.NoContent(http.StatusUnauthorized) | ||
56 | } | ||
57 | |||
58 | wsconn, err := h.Upgrader.Upgrade(c.Response(), c.Request(), nil) | ||
59 | if err != nil { | ||
60 | return err | ||
61 | } | ||
62 | defer wsconn.Close() | ||
63 | |||
64 | proxyconn, err := net.Dial("tcp", getConnectAddr(c)) | ||
65 | if err != nil { | ||
66 | return err | ||
67 | } | ||
68 | defer proxyconn.Close() | ||
69 | |||
70 | errc := make(chan error) | ||
71 | ws := &proxy.WebsocketReadWriter{W: wsconn} | ||
72 | |||
73 | go proxy.CopyWithErrors(proxyconn, ws, errc) | ||
74 | go proxy.CopyWithErrors(ws, proxyconn, errc) | ||
75 | |||
76 | <-errc | ||
77 | return nil | ||
78 | } | ||
diff --git a/app/controllers/register.go b/app/controllers/register.go new file mode 100644 index 0000000..8698bda --- /dev/null +++ b/app/controllers/register.go | |||
@@ -0,0 +1,78 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "net/http" | ||
5 | |||
6 | "code.crute.us/mcrute/golib/echo/session" | ||
7 | "code.crute.us/mcrute/ssh-proxy/app" | ||
8 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
9 | "github.com/go-webauthn/webauthn/protocol" | ||
10 | "github.com/go-webauthn/webauthn/webauthn" | ||
11 | "github.com/labstack/echo/v4" | ||
12 | ) | ||
13 | |||
14 | type RegisterController[T app.AppSession] struct { | ||
15 | Logger echo.Logger | ||
16 | Sessions session.Store[T] | ||
17 | Users models.UserStore | ||
18 | AuthSessions models.AuthSessionStore | ||
19 | Webauthn *webauthn.WebAuthn | ||
20 | } | ||
21 | |||
22 | func (a *RegisterController[T]) HandleStart(c echo.Context) error { | ||
23 | user, err := a.Users.Get(c.Request().Context(), c.Param("username")) | ||
24 | if err != nil { | ||
25 | a.Logger.Errorf("Error getting user: %s", err) | ||
26 | return c.NoContent(http.StatusNotFound) | ||
27 | } | ||
28 | |||
29 | request, sessionData, err := a.Webauthn.BeginRegistration(user) | ||
30 | if err != nil { | ||
31 | a.Logger.Errorf("Error creating webauthn request: %s", err) | ||
32 | return c.NoContent(http.StatusInternalServerError) | ||
33 | } | ||
34 | |||
35 | session := a.Sessions.Get(c) | ||
36 | s := session.Self() | ||
37 | s.WebauthnSession = sessionData | ||
38 | a.Sessions.Update(c, session) | ||
39 | |||
40 | return c.JSON(http.StatusOK, request) | ||
41 | } | ||
42 | |||
43 | func (a *RegisterController[T]) HandleFinish(c echo.Context) error { | ||
44 | user, err := a.Users.Get(c.Request().Context(), c.Param("username")) | ||
45 | if err != nil { | ||
46 | a.Logger.Errorf("Error getting user: %s", err) | ||
47 | return c.NoContent(http.StatusNotFound) | ||
48 | } | ||
49 | |||
50 | response, err := protocol.ParseCredentialCreationResponseBody(c.Request().Body) | ||
51 | if err != nil { | ||
52 | a.Logger.Errorf("Error parsing credential response: %s", err) | ||
53 | return c.NoContent(http.StatusBadRequest) | ||
54 | } | ||
55 | |||
56 | session := a.Sessions.Get(c) | ||
57 | s := session.Self() | ||
58 | |||
59 | if s.WebauthnSession == nil { | ||
60 | a.Logger.Errorf("Webauthn session is not set") | ||
61 | return c.NoContent(http.StatusBadRequest) | ||
62 | } | ||
63 | |||
64 | credential, err := a.Webauthn.CreateCredential(user, *s.WebauthnSession, response) | ||
65 | if err != nil { | ||
66 | a.Logger.Errorf("Error creating credential: %s", err) | ||
67 | return c.NoContent(http.StatusBadRequest) | ||
68 | } | ||
69 | |||
70 | user.Fido2Credentials = append(user.Fido2Credentials, *credential) | ||
71 | |||
72 | if err := a.Users.Upsert(c.Request().Context(), user); err != nil { | ||
73 | a.Logger.Errorf("Error saving user: %s", err) | ||
74 | return c.NoContent(http.StatusInternalServerError) | ||
75 | } | ||
76 | |||
77 | return c.NoContent(http.StatusOK) | ||
78 | } | ||
diff --git a/app/middleware/token_auth.go b/app/middleware/token_auth.go new file mode 100644 index 0000000..6454ddb --- /dev/null +++ b/app/middleware/token_auth.go | |||
@@ -0,0 +1,76 @@ | |||
1 | package middleware | ||
2 | |||
3 | import ( | ||
4 | "net/http" | ||
5 | "strings" | ||
6 | "time" | ||
7 | |||
8 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
9 | "github.com/labstack/echo/v4" | ||
10 | ) | ||
11 | |||
12 | const authorizedSession = "__ssh-proxy_authorized_session" | ||
13 | |||
14 | func GetAuthorizedSession(c echo.Context) *models.AuthSession { | ||
15 | ses := c.Get(authorizedSession) | ||
16 | if ses != nil { | ||
17 | return ses.(*models.AuthSession) | ||
18 | } | ||
19 | return nil | ||
20 | } | ||
21 | |||
22 | type TokenAuthMiddleware struct { | ||
23 | Logger echo.Logger | ||
24 | RequiredScope string | ||
25 | AuthSessions models.AuthSessionStore | ||
26 | } | ||
27 | |||
28 | func (m *TokenAuthMiddleware) Middleware(next echo.HandlerFunc) echo.HandlerFunc { | ||
29 | return func(c echo.Context) error { | ||
30 | authHeader := strings.SplitN(c.Request().Header.Get("Authorization"), " ", 2) | ||
31 | |||
32 | if len(authHeader) != 2 || strings.ToLower(authHeader[0]) != "bearer" { | ||
33 | return c.JSON(http.StatusBadRequest, models.Oauth2Error{ | ||
34 | Type: models.ErrInvalidRequest, | ||
35 | Description: "invalid authorization header", | ||
36 | }) | ||
37 | } | ||
38 | |||
39 | session, err := m.AuthSessions.GetByAccessCode(c.Request().Context(), authHeader[1]) | ||
40 | if err != nil { | ||
41 | return c.JSON(http.StatusUnauthorized, models.Oauth2Error{ | ||
42 | Type: models.ErrAccessDenied, | ||
43 | }) | ||
44 | } | ||
45 | |||
46 | if time.Now().After(session.Expires) { | ||
47 | return c.JSON(http.StatusUnauthorized, models.Oauth2Error{ | ||
48 | Type: models.ErrAccessDenied, | ||
49 | }) | ||
50 | } | ||
51 | |||
52 | foundScope := false | ||
53 | for _, s := range session.Scope { | ||
54 | if s == m.RequiredScope { | ||
55 | foundScope = true | ||
56 | break | ||
57 | } | ||
58 | } | ||
59 | |||
60 | if !foundScope { | ||
61 | return c.JSON(http.StatusUnauthorized, models.Oauth2Error{ | ||
62 | Type: models.ErrAccessDenied, | ||
63 | }) | ||
64 | } | ||
65 | |||
66 | if session.IsRegistration { | ||
67 | return c.JSON(http.StatusUnauthorized, models.Oauth2Error{ | ||
68 | Type: models.ErrAccessDenied, | ||
69 | }) | ||
70 | } | ||
71 | |||
72 | c.Set(authorizedSession, session) | ||
73 | |||
74 | return next(c) | ||
75 | } | ||
76 | } | ||
diff --git a/app/models/auth_session.go b/app/models/auth_session.go new file mode 100644 index 0000000..0b86b16 --- /dev/null +++ b/app/models/auth_session.go | |||
@@ -0,0 +1,75 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "strings" | ||
6 | "time" | ||
7 | ) | ||
8 | |||
9 | type AuthSession struct { | ||
10 | DeviceCode string `bson:"_id"` | ||
11 | ClientId string | ||
12 | UserCode string | ||
13 | AccessCode string | ||
14 | Challenge string | ||
15 | ChallengeMethod string | ||
16 | UserId string | ||
17 | IsRegistration bool | ||
18 | Scope []string | ||
19 | Expires time.Time | ||
20 | Deleted *time.Time | ||
21 | } | ||
22 | |||
23 | func NewAuthSession(client string, expires time.Time) *AuthSession { | ||
24 | return &AuthSession{ | ||
25 | DeviceCode: createDeviceCode(), | ||
26 | UserCode: createUserCode(), | ||
27 | Expires: expires, | ||
28 | ClientId: client, | ||
29 | } | ||
30 | } | ||
31 | |||
32 | func (s *AuthSession) GenerateAccessCode() { | ||
33 | s.AccessCode = createDeviceCode() | ||
34 | } | ||
35 | |||
36 | func (s *AuthSession) RecordId() string { | ||
37 | return s.DeviceCode | ||
38 | } | ||
39 | |||
40 | func (s *AuthSession) MarkDeleted(t time.Time) { | ||
41 | s.Deleted = &t | ||
42 | } | ||
43 | |||
44 | func (s *AuthSession) SetChallenge(challenge string, method PKCEChallengeType) { | ||
45 | s.Challenge = challenge | ||
46 | s.ChallengeMethod = string(method) | ||
47 | } | ||
48 | |||
49 | func (s *AuthSession) SetScopeString(scope string) { | ||
50 | s.Scope = strings.Split(scope, " ") | ||
51 | } | ||
52 | |||
53 | func (s *AuthSession) HasAnyScopes() bool { | ||
54 | return len(s.Scope) > 0 | ||
55 | } | ||
56 | |||
57 | func (s *AuthSession) HasScope(scope string) bool { | ||
58 | for _, c := range s.Scope { | ||
59 | if c == scope { | ||
60 | return true | ||
61 | } | ||
62 | } | ||
63 | return false | ||
64 | } | ||
65 | |||
66 | type AuthSessionStore interface { | ||
67 | List(ctx context.Context) ([]*AuthSession, error) | ||
68 | ListAll(ctx context.Context) ([]*AuthSession, error) | ||
69 | Get(ctx context.Context, name string) (*AuthSession, error) | ||
70 | GetByUserCode(ctx context.Context, userCode string) (*AuthSession, error) | ||
71 | GetByAccessCode(ctx context.Context, userCode string) (*AuthSession, error) | ||
72 | Insert(ctx context.Context, m *AuthSession) error | ||
73 | Upsert(ctx context.Context, m *AuthSession) error | ||
74 | Delete(ctx context.Context, m *AuthSession) error | ||
75 | } | ||
diff --git a/app/models/auth_session_mongodb.go b/app/models/auth_session_mongodb.go new file mode 100644 index 0000000..fc5f5dd --- /dev/null +++ b/app/models/auth_session_mongodb.go | |||
@@ -0,0 +1,45 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "time" | ||
6 | |||
7 | "code.crute.us/mcrute/ssh-proxy/db" | ||
8 | |||
9 | "go.mongodb.org/mongo-driver/bson" | ||
10 | "go.mongodb.org/mongo-driver/bson/primitive" | ||
11 | ) | ||
12 | |||
13 | type AuthSessionStoreMongodb struct { | ||
14 | *db.MongoDbBasicStore[*AuthSession] | ||
15 | } | ||
16 | |||
17 | var _ AuthSessionStore = (*AuthSessionStoreMongodb)(nil) | ||
18 | |||
19 | func (s *AuthSessionStoreMongodb) getBy(ctx context.Context, field, value string) (*AuthSession, error) { | ||
20 | var out AuthSession | ||
21 | |||
22 | if err := s.Db.Collection(s.CollectionName).FindOne(ctx, &bson.M{ | ||
23 | field: value, | ||
24 | "expires": bson.M{ | ||
25 | "$gte": primitive.NewDateTimeFromTime(time.Now()), | ||
26 | }, | ||
27 | }).Decode(&out); err != nil { | ||
28 | return nil, err | ||
29 | } | ||
30 | |||
31 | return &out, nil | ||
32 | } | ||
33 | |||
34 | func (s *AuthSessionStoreMongodb) GetByUserCode(ctx context.Context, userCode string) (*AuthSession, error) { | ||
35 | return s.getBy(ctx, "usercode", userCode) | ||
36 | } | ||
37 | |||
38 | func (s *AuthSessionStoreMongodb) GetByAccessCode(ctx context.Context, accessCode string) (*AuthSession, error) { | ||
39 | return s.getBy(ctx, "accesscode", accessCode) | ||
40 | } | ||
41 | |||
42 | func (s *AuthSessionStoreMongodb) Insert(ctx context.Context, session *AuthSession) error { | ||
43 | _, err := s.Db.Collection(s.CollectionName).InsertOne(ctx, session) | ||
44 | return err | ||
45 | } | ||
diff --git a/app/models/auth_session_util.go b/app/models/auth_session_util.go new file mode 100644 index 0000000..1f1474a --- /dev/null +++ b/app/models/auth_session_util.go | |||
@@ -0,0 +1,25 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "crypto/rand" | ||
5 | "encoding/base32" | ||
6 | "encoding/base64" | ||
7 | "fmt" | ||
8 | ) | ||
9 | |||
10 | func createDeviceCode() string { | ||
11 | buf := make([]byte, 32) | ||
12 | if _, err := rand.Read(buf); err != nil { | ||
13 | panic(err) | ||
14 | } | ||
15 | return base64.URLEncoding.EncodeToString(buf) | ||
16 | } | ||
17 | |||
18 | func createUserCode() string { | ||
19 | buf := make([]byte, 32) | ||
20 | if _, err := rand.Read(buf); err != nil { | ||
21 | panic(err) | ||
22 | } | ||
23 | userCodeRaw := base32.StdEncoding.EncodeToString(buf) | ||
24 | return fmt.Sprintf("%s-%s", userCodeRaw[0:4], userCodeRaw[5:9]) | ||
25 | } | ||
diff --git a/app/models/oauth2.go b/app/models/oauth2.go new file mode 100644 index 0000000..9bfde0a --- /dev/null +++ b/app/models/oauth2.go | |||
@@ -0,0 +1,103 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "crypto/rand" | ||
5 | "crypto/sha256" | ||
6 | "crypto/subtle" | ||
7 | "encoding/base64" | ||
8 | "fmt" | ||
9 | ) | ||
10 | |||
11 | const ( | ||
12 | DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" | ||
13 | ) | ||
14 | |||
15 | type AuthorizationRequest struct { | ||
16 | Challenge string `url:"code_challenge" form:"code_challenge" json:"code_challenge"` // RFC7636 | ||
17 | ChallengeMethod PKCEChallengeType `url:"code_challenge_method" form:"code_challenge_method" json:"code_challenge_method"` // RFC7636 | ||
18 | ClientId string `url:"client_id" form:"client_id" json:"client_id"` | ||
19 | Scope string `url:"scope" form:"scope" json:"scope"` | ||
20 | } | ||
21 | |||
22 | type DeviceAuthorizationResponse struct { | ||
23 | DeviceCode string `json:"device_code"` // REQUIRED | ||
24 | UserCode string `json:"user_code"` // REQUIRED | ||
25 | VerificationUri string `json:"verification_uri"` // REQUIRED | ||
26 | VerificationUriComplete string `json:"verification_uri_complete,omitempty"` | ||
27 | ExpiresIn int `json:"expires_in,omitempty"` | ||
28 | Interval int `json:"interval,omitempty"` | ||
29 | } | ||
30 | |||
31 | type DeviceAccessTokenRequest struct { | ||
32 | GrantType string `url:"grant_type" form:"grant_type" json:"grant_type"` | ||
33 | DeviceCode string `url:"device_code" form:"device_code" json:"device_code"` | ||
34 | ClientId string `url:"client_id" form:"client_id" json:"client_id"` | ||
35 | CodeVerifier string `url:"code_verifier" form:"code_verifier" json:"code_verifier"` | ||
36 | } | ||
37 | |||
38 | type AccessTokenResponse struct { | ||
39 | AccessToken string `json:"access_token"` | ||
40 | TokenType string `json:"token_type"` // Must be Bearer | ||
41 | ExpiresIn string `json:"expires_in,omitempty"` // Lifetime in seconds | ||
42 | RefreshToken string `json:"refresh_token,omitempty"` | ||
43 | Scope string `json:"scope,omitempty"` | ||
44 | } | ||
45 | |||
46 | type AuthorizationError string | ||
47 | |||
48 | const ( | ||
49 | ErrInvalidRequest AuthorizationError = "invalid_request" | ||
50 | ErrInvalidClient = "invalid_client" | ||
51 | ErrInvalidGrant = "invalid_grant" | ||
52 | ErrUnauthorizedClient = "unauthorized_client" | ||
53 | ErrUnsupportedGrantType = "unsupported_grant_type" | ||
54 | ErrInvalidScope = "invalid_scope" | ||
55 | ErrAuthorizationPending = "authorization_pending" // RFC7636 | ||
56 | ErrSlowDown = "slow_down" // RFC7636 | ||
57 | ErrAccessDenied = "access_denied" // RFC7636 | ||
58 | ErrExpiredToken = "expired_token" // RFC7636 | ||
59 | ) | ||
60 | |||
61 | type Oauth2Error struct { | ||
62 | Type AuthorizationError `json:"error"` | ||
63 | Description string `json:"error_description,omitempty"` | ||
64 | Uri string `json:"error_uri,omitempty"` | ||
65 | } | ||
66 | |||
67 | func (e Oauth2Error) Error() string { | ||
68 | if e.Description == "" { | ||
69 | return fmt.Sprintf("Oauth2Error: %s", e.Type) | ||
70 | } else { | ||
71 | return fmt.Sprintf("Oauth2Error: %s %s", e.Type, e.Description) | ||
72 | } | ||
73 | } | ||
74 | |||
75 | type PKCEChallengeType string | ||
76 | |||
77 | const ( | ||
78 | ChallengePlain PKCEChallengeType = "plain" | ||
79 | ChallengeS256 = "S256" | ||
80 | ) | ||
81 | |||
82 | type PKCEChallenge struct { | ||
83 | Verifier string | ||
84 | } | ||
85 | |||
86 | func NewPKCEChallenge() (*PKCEChallenge, error) { | ||
87 | buf := make([]byte, 32) | ||
88 | if _, err := rand.Read(buf); err != nil { | ||
89 | return nil, err | ||
90 | } | ||
91 | return &PKCEChallenge{ | ||
92 | Verifier: base64.URLEncoding.EncodeToString(buf), | ||
93 | }, nil | ||
94 | } | ||
95 | |||
96 | func (c *PKCEChallenge) Challenge() string { | ||
97 | hash := sha256.Sum256([]byte(c.Verifier)) | ||
98 | return base64.URLEncoding.EncodeToString(hash[:]) | ||
99 | } | ||
100 | |||
101 | func (c *PKCEChallenge) EqualString(o string) bool { | ||
102 | return subtle.ConstantTimeCompare([]byte(o), []byte(c.Challenge())) != 1 | ||
103 | } | ||
diff --git a/app/models/oauth_client.go b/app/models/oauth_client.go new file mode 100644 index 0000000..2f30087 --- /dev/null +++ b/app/models/oauth_client.go | |||
@@ -0,0 +1,27 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "time" | ||
6 | ) | ||
7 | |||
8 | type OauthClient struct { | ||
9 | Id string `bson:"_id"` | ||
10 | Deleted *time.Time | ||
11 | } | ||
12 | |||
13 | func (c *OauthClient) RecordId() string { | ||
14 | return c.Id | ||
15 | } | ||
16 | |||
17 | func (c *OauthClient) MarkDeleted(t time.Time) { | ||
18 | c.Deleted = &t | ||
19 | } | ||
20 | |||
21 | type OauthClientStore interface { | ||
22 | List(ctx context.Context) ([]*OauthClient, error) | ||
23 | ListAll(ctx context.Context) ([]*OauthClient, error) | ||
24 | Get(ctx context.Context, name string) (*OauthClient, error) | ||
25 | Upsert(ctx context.Context, m *OauthClient) error | ||
26 | Delete(ctx context.Context, m *OauthClient) error | ||
27 | } | ||
diff --git a/app/models/user.go b/app/models/user.go new file mode 100644 index 0000000..5c9ec90 --- /dev/null +++ b/app/models/user.go | |||
@@ -0,0 +1,63 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "time" | ||
6 | |||
7 | "github.com/go-webauthn/webauthn/webauthn" | ||
8 | ) | ||
9 | |||
10 | type User struct { | ||
11 | Username string `bson:"_id"` | ||
12 | DisplayName string | ||
13 | AllowedHosts []string | ||
14 | Fido2Credentials []webauthn.Credential | ||
15 | Deleted *time.Time | ||
16 | } | ||
17 | |||
18 | var _ webauthn.User = (*User)(nil) | ||
19 | |||
20 | func (u *User) RecordId() string { | ||
21 | return u.Username | ||
22 | } | ||
23 | |||
24 | func (u *User) MarkDeleted(t time.Time) { | ||
25 | u.Deleted = &t | ||
26 | } | ||
27 | |||
28 | func (u *User) WebAuthnID() []byte { | ||
29 | return []byte(u.Username) | ||
30 | } | ||
31 | |||
32 | func (u *User) WebAuthnName() string { | ||
33 | return u.Username | ||
34 | } | ||
35 | |||
36 | func (u *User) WebAuthnDisplayName() string { | ||
37 | return u.DisplayName | ||
38 | } | ||
39 | |||
40 | func (u *User) WebAuthnCredentials() []webauthn.Credential { | ||
41 | return u.Fido2Credentials | ||
42 | } | ||
43 | |||
44 | func (u *User) WebAuthnIcon() string { | ||
45 | return "" | ||
46 | } | ||
47 | |||
48 | func (u *User) AuthorizedForHost(host string) bool { | ||
49 | for _, c := range u.AllowedHosts { | ||
50 | if host == c { | ||
51 | return true | ||
52 | } | ||
53 | } | ||
54 | return false | ||
55 | } | ||
56 | |||
57 | type UserStore interface { | ||
58 | List(ctx context.Context) ([]*User, error) | ||
59 | ListAll(ctx context.Context) ([]*User, error) | ||
60 | Get(ctx context.Context, name string) (*User, error) | ||
61 | Upsert(ctx context.Context, m *User) error | ||
62 | Delete(ctx context.Context, m *User) error | ||
63 | } | ||
diff --git a/app/session.go b/app/session.go new file mode 100644 index 0000000..58aa13d --- /dev/null +++ b/app/session.go | |||
@@ -0,0 +1,46 @@ | |||
1 | package app | ||
2 | |||
3 | import ( | ||
4 | "time" | ||
5 | |||
6 | "code.crute.us/mcrute/golib/echo/middleware" | ||
7 | "code.crute.us/mcrute/golib/echo/session" | ||
8 | "github.com/go-webauthn/webauthn/webauthn" | ||
9 | "github.com/labstack/echo/v4" | ||
10 | ) | ||
11 | |||
12 | type AppSession interface { | ||
13 | session.Session | ||
14 | middleware.CSRFAwareSession | ||
15 | Self() *Session | ||
16 | } | ||
17 | |||
18 | type Session struct { | ||
19 | Expiration time.Time | ||
20 | CSRFToken string | ||
21 | WebauthnSession *webauthn.SessionData | ||
22 | } | ||
23 | |||
24 | var _ AppSession = (*Session)(nil) | ||
25 | |||
26 | func NewSession(c echo.Context) *Session { | ||
27 | return &Session{ | ||
28 | Expiration: time.Now().Add(365 * 24 * time.Hour), | ||
29 | } | ||
30 | } | ||
31 | |||
32 | func (s *Session) Self() *Session { | ||
33 | return s | ||
34 | } | ||
35 | |||
36 | func (s *Session) Expires() time.Time { | ||
37 | return s.Expiration | ||
38 | } | ||
39 | |||
40 | func (s *Session) GetCSRFSecret() string { | ||
41 | return s.CSRFToken | ||
42 | } | ||
43 | |||
44 | func (s *Session) SetCSRFSecret(secret string) { | ||
45 | s.CSRFToken = secret | ||
46 | } | ||
diff --git a/app/templates.go b/app/templates.go new file mode 100644 index 0000000..52ded7a --- /dev/null +++ b/app/templates.go | |||
@@ -0,0 +1,18 @@ | |||
1 | package app | ||
2 | |||
3 | import ( | ||
4 | "code.crute.us/mcrute/golib/echo/controller" | ||
5 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
6 | ) | ||
7 | |||
8 | type PageContext struct { | ||
9 | PageName string | ||
10 | Year int | ||
11 | RenderTime string | ||
12 | Flags *controller.FeatureFlags | ||
13 | Context *controller.PageContext | ||
14 | CSRFToken string | ||
15 | AuthenticatedUser *models.User | ||
16 | Model any // For pages with one model | ||
17 | Models any // For pages with a collection of models | ||
18 | } | ||