summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorMike Crute <mike@crute.us>2023-07-29 12:15:13 -0700
committerMike Crute <mike@crute.us>2023-07-29 12:15:13 -0700
commit4e995f9e6c3adc43a361b6fa9b976d25378f1594 (patch)
tree862642149583fa4ad662edfe0b31a7d65b8e302e /app
parentfea07831eadd35532055ec16fc43b0cde56a54b1 (diff)
downloadwebsocket_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.go45
-rw-r--r--app/controllers/ca.go172
-rw-r--r--app/controllers/login.go117
-rw-r--r--app/controllers/oauth2_device.go129
-rw-r--r--app/controllers/proxy.go78
-rw-r--r--app/controllers/register.go78
-rw-r--r--app/middleware/token_auth.go76
-rw-r--r--app/models/auth_session.go75
-rw-r--r--app/models/auth_session_mongodb.go45
-rw-r--r--app/models/auth_session_util.go25
-rw-r--r--app/models/oauth2.go103
-rw-r--r--app/models/oauth_client.go27
-rw-r--r--app/models/user.go63
-rw-r--r--app/session.go46
-rw-r--r--app/templates.go18
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 @@
1package app
2
3import "time"
4
5type 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
26var 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 @@
1package controllers
2
3import (
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
17type CASecret struct {
18 Key string `mapstructure:"key"`
19}
20
21type CAHandlerConfig struct {
22 Logger echo.Logger
23 Users models.UserStore
24 Expiration time.Duration
25 Secret CASecret
26}
27
28type CAHandler struct {
29 Logger echo.Logger
30 Users models.UserStore
31 Expiration time.Duration
32 signer ssh.Signer
33}
34
35func 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
51func (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
86func (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
96func (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 @@
1package controllers
2
3import (
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
18type 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
27func (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
48func (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 @@
1package controllers
2
3import (
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
15func 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
22type 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
31func (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
85func (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 @@
1package controllers
2
3import (
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
17type ProxyHandler struct {
18 Logger echo.Logger
19 Upgrader websocket.Upgrader
20 Users models.UserStore
21}
22
23func 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
31func (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
52func (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 @@
1package controllers
2
3import (
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
14type 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
22func (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
43func (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 @@
1package middleware
2
3import (
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
12const authorizedSession = "__ssh-proxy_authorized_session"
13
14func 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
22type TokenAuthMiddleware struct {
23 Logger echo.Logger
24 RequiredScope string
25 AuthSessions models.AuthSessionStore
26}
27
28func (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 @@
1package models
2
3import (
4 "context"
5 "strings"
6 "time"
7)
8
9type 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
23func 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
32func (s *AuthSession) GenerateAccessCode() {
33 s.AccessCode = createDeviceCode()
34}
35
36func (s *AuthSession) RecordId() string {
37 return s.DeviceCode
38}
39
40func (s *AuthSession) MarkDeleted(t time.Time) {
41 s.Deleted = &t
42}
43
44func (s *AuthSession) SetChallenge(challenge string, method PKCEChallengeType) {
45 s.Challenge = challenge
46 s.ChallengeMethod = string(method)
47}
48
49func (s *AuthSession) SetScopeString(scope string) {
50 s.Scope = strings.Split(scope, " ")
51}
52
53func (s *AuthSession) HasAnyScopes() bool {
54 return len(s.Scope) > 0
55}
56
57func (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
66type 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 @@
1package models
2
3import (
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
13type AuthSessionStoreMongodb struct {
14 *db.MongoDbBasicStore[*AuthSession]
15}
16
17var _ AuthSessionStore = (*AuthSessionStoreMongodb)(nil)
18
19func (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
34func (s *AuthSessionStoreMongodb) GetByUserCode(ctx context.Context, userCode string) (*AuthSession, error) {
35 return s.getBy(ctx, "usercode", userCode)
36}
37
38func (s *AuthSessionStoreMongodb) GetByAccessCode(ctx context.Context, accessCode string) (*AuthSession, error) {
39 return s.getBy(ctx, "accesscode", accessCode)
40}
41
42func (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 @@
1package models
2
3import (
4 "crypto/rand"
5 "encoding/base32"
6 "encoding/base64"
7 "fmt"
8)
9
10func 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
18func 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 @@
1package models
2
3import (
4 "crypto/rand"
5 "crypto/sha256"
6 "crypto/subtle"
7 "encoding/base64"
8 "fmt"
9)
10
11const (
12 DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
13)
14
15type 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
22type 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
31type 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
38type 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
46type AuthorizationError string
47
48const (
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
61type Oauth2Error struct {
62 Type AuthorizationError `json:"error"`
63 Description string `json:"error_description,omitempty"`
64 Uri string `json:"error_uri,omitempty"`
65}
66
67func (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
75type PKCEChallengeType string
76
77const (
78 ChallengePlain PKCEChallengeType = "plain"
79 ChallengeS256 = "S256"
80)
81
82type PKCEChallenge struct {
83 Verifier string
84}
85
86func 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
96func (c *PKCEChallenge) Challenge() string {
97 hash := sha256.Sum256([]byte(c.Verifier))
98 return base64.URLEncoding.EncodeToString(hash[:])
99}
100
101func (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 @@
1package models
2
3import (
4 "context"
5 "time"
6)
7
8type OauthClient struct {
9 Id string `bson:"_id"`
10 Deleted *time.Time
11}
12
13func (c *OauthClient) RecordId() string {
14 return c.Id
15}
16
17func (c *OauthClient) MarkDeleted(t time.Time) {
18 c.Deleted = &t
19}
20
21type 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 @@
1package models
2
3import (
4 "context"
5 "time"
6
7 "github.com/go-webauthn/webauthn/webauthn"
8)
9
10type User struct {
11 Username string `bson:"_id"`
12 DisplayName string
13 AllowedHosts []string
14 Fido2Credentials []webauthn.Credential
15 Deleted *time.Time
16}
17
18var _ webauthn.User = (*User)(nil)
19
20func (u *User) RecordId() string {
21 return u.Username
22}
23
24func (u *User) MarkDeleted(t time.Time) {
25 u.Deleted = &t
26}
27
28func (u *User) WebAuthnID() []byte {
29 return []byte(u.Username)
30}
31
32func (u *User) WebAuthnName() string {
33 return u.Username
34}
35
36func (u *User) WebAuthnDisplayName() string {
37 return u.DisplayName
38}
39
40func (u *User) WebAuthnCredentials() []webauthn.Credential {
41 return u.Fido2Credentials
42}
43
44func (u *User) WebAuthnIcon() string {
45 return ""
46}
47
48func (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
57type 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 @@
1package app
2
3import (
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
12type AppSession interface {
13 session.Session
14 middleware.CSRFAwareSession
15 Self() *Session
16}
17
18type Session struct {
19 Expiration time.Time
20 CSRFToken string
21 WebauthnSession *webauthn.SessionData
22}
23
24var _ AppSession = (*Session)(nil)
25
26func NewSession(c echo.Context) *Session {
27 return &Session{
28 Expiration: time.Now().Add(365 * 24 * time.Hour),
29 }
30}
31
32func (s *Session) Self() *Session {
33 return s
34}
35
36func (s *Session) Expires() time.Time {
37 return s.Expiration
38}
39
40func (s *Session) GetCSRFSecret() string {
41 return s.CSRFToken
42}
43
44func (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 @@
1package app
2
3import (
4 "code.crute.us/mcrute/golib/echo/controller"
5 "code.crute.us/mcrute/ssh-proxy/app/models"
6)
7
8type 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}