diff options
Diffstat (limited to 'app/middleware/auth.go')
-rw-r--r-- | app/middleware/auth.go | 212 |
1 files changed, 212 insertions, 0 deletions
diff --git a/app/middleware/auth.go b/app/middleware/auth.go new file mode 100644 index 0000000..167d261 --- /dev/null +++ b/app/middleware/auth.go | |||
@@ -0,0 +1,212 @@ | |||
1 | package middleware | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "fmt" | ||
6 | "net/http" | ||
7 | "strings" | ||
8 | "time" | ||
9 | |||
10 | "code.crute.us/mcrute/cloud-identity-broker/app/models" | ||
11 | "code.crute.us/mcrute/cloud-identity-broker/auth" | ||
12 | "code.crute.us/mcrute/cloud-identity-broker/auth/github" | ||
13 | |||
14 | "github.com/labstack/echo/v4" | ||
15 | ) | ||
16 | |||
17 | // canRegisterUrls is an interface that identifies what about an HTTP router is | ||
18 | // needed by this middleware. This mainly exists to work around the fact that | ||
19 | // our server is actually a golib.EchoWrapper and not an echo.Echo. | ||
20 | type canRegisterUrls interface { | ||
21 | GET(string, echo.HandlerFunc, ...echo.MiddlewareFunc) *echo.Route | ||
22 | } | ||
23 | |||
24 | const ( | ||
25 | authPrincipalContextKey = "broker.AuthorizedPrincipal" | ||
26 | gitHubTokenCookie = "github-token" | ||
27 | gitHubStateCookie = "github-state" | ||
28 | oauthReturnUrl = "/github-auth" | ||
29 | ) | ||
30 | |||
31 | // GetAuthorizedPrincipal returns the user principal object from the request | ||
32 | // context and casts it correctly. Will return error if there is no principal | ||
33 | // or if the principal is of the incorrect type. | ||
34 | // | ||
35 | // Note that use of this function implies that AuthenticationMiddleware is used | ||
36 | // somewhere in the stack before the handler calling this function is | ||
37 | // dispatched. | ||
38 | func GetAuthorizedPrincipal(c echo.Context) (*models.User, error) { | ||
39 | rp := c.Get(authPrincipalContextKey) | ||
40 | if rp == nil { | ||
41 | return nil, fmt.Errorf("No principal set in request") | ||
42 | } | ||
43 | principal, ok := rp.(*models.User) | ||
44 | if !ok { | ||
45 | return nil, fmt.Errorf("Principal in request is not of User type") | ||
46 | } | ||
47 | return principal, nil | ||
48 | } | ||
49 | |||
50 | type AuthenticationMiddleware struct { | ||
51 | Store models.UserStore | ||
52 | JWTManager *auth.JWTManager | ||
53 | GitHub *github.GitHubAuthenticator | ||
54 | CookieDuration time.Duration | ||
55 | } | ||
56 | |||
57 | func (m *AuthenticationMiddleware) redirectToGitHubAuth(c echo.Context) error { | ||
58 | redir, state := m.GitHub.GetAuthRedirect() | ||
59 | |||
60 | c.SetCookie(&http.Cookie{ | ||
61 | Name: gitHubStateCookie, | ||
62 | Value: state, | ||
63 | Path: "/", | ||
64 | Secure: true, | ||
65 | HttpOnly: true, | ||
66 | SameSite: http.SameSiteStrictMode, | ||
67 | }) | ||
68 | |||
69 | return c.Redirect(http.StatusFound, redir) | ||
70 | } | ||
71 | |||
72 | // RegisterUrls registers the URLs required by this middleware and handler with an echo instance. | ||
73 | // | ||
74 | // This is here instead of in the web main because these paths are encoded in | ||
75 | // the configuration for the GitHub application so changing them requires | ||
76 | // addition changes to that configuration. | ||
77 | func (m *AuthenticationMiddleware) RegisterUrls(e canRegisterUrls) { | ||
78 | e.GET(oauthReturnUrl, m.HandleCompleteLogin) | ||
79 | } | ||
80 | |||
81 | // Middleware does user authentication based on either an X-API-Key header, | ||
82 | // Authorization header, or GitHub cookie depending on how the request is | ||
83 | // phrased. | ||
84 | // | ||
85 | // If the request has either an X-API-Key or an Authorization Bearer header | ||
86 | // then that must pass validation with the downstream validation logic. | ||
87 | // Failures through this path are hard failures and the only way to re-try them | ||
88 | // is to authenticate with a new token. The underlying assumption is that only | ||
89 | // programmatic access goes through this path so redirecting to interactive | ||
90 | // authentication is pointless. | ||
91 | // | ||
92 | // In the absence of those headers it's assumed that the user is interactive | ||
93 | // and their auth cookie will be read and validated (by the exact same logic | ||
94 | // that an API key is validated, they're the same format) but the failure case | ||
95 | // here will redirect the user to GitHub for interactive auth. | ||
96 | // | ||
97 | // X-API-Key should be considered deprecated and the Authorization header with | ||
98 | // a type of Bearer should be used instead. This is more in-line with Oauth 2 | ||
99 | // style authentication. However, for now this middleware continues to support | ||
100 | // X-API-Key for to not break legacy API clients. | ||
101 | func (m *AuthenticationMiddleware) Middleware(next echo.HandlerFunc) echo.HandlerFunc { | ||
102 | return func(c echo.Context) error { | ||
103 | token := c.Request().Header.Get("X-API-Key") | ||
104 | if token == "" { | ||
105 | tp := strings.Split(c.Request().Header.Get(echo.HeaderAuthorization), " ") | ||
106 | if len(tp) == 2 && tp[0] == "Bearer" { | ||
107 | token = tp[1] | ||
108 | } | ||
109 | } | ||
110 | |||
111 | // If an API key is specified this is a success or failure path. There | ||
112 | // is no option to authenticate to GitHub as would an interactive user. | ||
113 | if token != "" { | ||
114 | u, err := m.JWTManager.Validate(token) | ||
115 | if err != nil { | ||
116 | c.Logger().Debugf("Error validating JWT: %w", err) | ||
117 | return echo.ErrUnauthorized | ||
118 | } | ||
119 | c.Set(authPrincipalContextKey, u) | ||
120 | return next(c) | ||
121 | } | ||
122 | |||
123 | // Kick them to GitHub auth if they have no auth cookie | ||
124 | authCookie, err := c.Cookie(gitHubTokenCookie) | ||
125 | if err != nil { | ||
126 | return m.redirectToGitHubAuth(c) | ||
127 | } | ||
128 | |||
129 | // If they fail the check them bounce them through logout to remove | ||
130 | // their existing cookies which should then bounce them back through | ||
131 | // GitHub auth, which will eventually land them back here. | ||
132 | u, err := m.JWTManager.Validate(authCookie.Value) | ||
133 | if err != nil { | ||
134 | c.Logger().Debugf("Error validating JWT: %w", err) | ||
135 | return c.Redirect(http.StatusFound, "/logout") | ||
136 | } | ||
137 | |||
138 | c.Set(authPrincipalContextKey, u) | ||
139 | return next(c) | ||
140 | } | ||
141 | } | ||
142 | |||
143 | // HandleCompleteLogin handles the Oauth 2 code flow. It receives the auth code | ||
144 | // and uses that to retrieve the auth token. This sets the user's auth cookie | ||
145 | // to a authenticated JWT. | ||
146 | // | ||
147 | // This is redirected-to by the Oauth authorization server and should never be | ||
148 | // hit directly by a user or script. | ||
149 | func (m *AuthenticationMiddleware) HandleCompleteLogin(c echo.Context) error { | ||
150 | ctx := context.Background() | ||
151 | |||
152 | code, state := c.QueryParam("code"), c.QueryParam("state") | ||
153 | if code == "" || state == "" { | ||
154 | return echo.ErrBadRequest | ||
155 | } | ||
156 | |||
157 | ghState, err := c.Cookie(gitHubStateCookie) | ||
158 | if err != nil || ghState.Value == "" { | ||
159 | return echo.ErrBadRequest | ||
160 | } | ||
161 | |||
162 | if ghState.Value != state { | ||
163 | return echo.ErrBadRequest | ||
164 | } | ||
165 | |||
166 | token, err := m.GitHub.GetTokens(code) | ||
167 | if err != nil { | ||
168 | return echo.ErrBadRequest | ||
169 | } | ||
170 | |||
171 | user, err := m.GitHub.GetUsernameWithToken(token.AccessToken) | ||
172 | if err != nil { | ||
173 | c.Logger().Debugf("Error getting GitHub username with token: %w", err) | ||
174 | return echo.ErrUnauthorized | ||
175 | } | ||
176 | |||
177 | dbUser, err := m.Store.Get(ctx, user) | ||
178 | if err != nil { | ||
179 | c.Logger().Errorf("GitHub user %s does not have access to app", user) | ||
180 | return echo.ErrUnauthorized | ||
181 | } | ||
182 | |||
183 | jwt, sk, err := m.JWTManager.CreateForUser(dbUser) | ||
184 | if err != nil { | ||
185 | return echo.ErrInternalServerError | ||
186 | } | ||
187 | |||
188 | dbUser.AddKey(sk) | ||
189 | dbUser.GCKeys() // This is a convenient place to do it | ||
190 | |||
191 | dbUser.AddToken(&models.AuthToken{ | ||
192 | Kind: "github", | ||
193 | Token: token.AccessToken, | ||
194 | RefreshToken: token.RefreshToken, | ||
195 | }) | ||
196 | |||
197 | if err := m.Store.Put(ctx, dbUser); err != nil { | ||
198 | return echo.ErrInternalServerError | ||
199 | } | ||
200 | |||
201 | c.SetCookie(&http.Cookie{ | ||
202 | Name: gitHubTokenCookie, | ||
203 | Value: jwt, | ||
204 | Path: "/", | ||
205 | MaxAge: int(m.CookieDuration.Seconds()), | ||
206 | Secure: true, | ||
207 | HttpOnly: true, | ||
208 | SameSite: http.SameSiteStrictMode, | ||
209 | }) | ||
210 | |||
211 | return c.Redirect(http.StatusFound, "/") | ||
212 | } | ||