1package handler
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10
11 "github.com/bnema/gordon/internal/db"
12 "github.com/bnema/gordon/internal/db/queries"
13 "github.com/bnema/gordon/internal/server"
14 "github.com/bnema/gordon/internal/templating/render"
15 "github.com/gorilla/sessions"
16 _ "github.com/joho/godotenv/autoload"
17 "github.com/labstack/echo-contrib/session"
18 "github.com/labstack/echo/v4"
19)
20
21var urlToken string
22
23// compareGordonToken compares the token from the URL query parameter with the one from the config.yml
24func CompareGordonToken(c echo.Context, a *server.App) error {
25 configToken, err := a.Config.GetToken()
26 if err != nil {
27 return err
28 }
29
30 if urlToken != configToken {
31 return fmt.Errorf("token is empty or not valid")
32 }
33
34 return nil
35}
36
37// RenderLoginPage renders the login.html template
38func RenderLoginPage(c echo.Context, a *server.App) error {
39 urlToken = c.QueryParam("token")
40 data := map[string]interface{}{
41 "Title": "Login",
42 }
43
44 // Navigate inside the fs.FS to get the template
45 path := "html/login"
46 rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)
47 if err != nil {
48 return err
49 }
50
51 renderedHTML, err := rendererData.Render(data, a)
52 if err != nil {
53 return err
54 }
55
56 return c.HTML(200, renderedHTML)
57}
58
59// StartOAuthGithub starts the Github OAuth flow
60func StartOAuthGithub(c echo.Context, a *server.App) error {
61 // Check if the session is already here and valid before going for the oauth flow
62 sess, err := getSession(c)
63 if err != nil {
64 return err
65 }
66
67 // Check if the session values are valid
68 accountID, ok := sess.Values["accountID"].(string)
69 if ok && accountID != "" {
70 return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path) // StatusSeeOther is HTTP 303
71 }
72
73 //Initiate the Github OAuth flow
74 clientID := os.Getenv("GITHUB_APP_ID")
75 redirectDomain := a.GenerateOauthCallbackURL()
76 encodedState := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
77 // Redirect to Gordon's Proxy to grab the oauth access
78 oauthURL := fmt.Sprintf(
79 "https://gordon.bnema.dev/github-proxy/authorize?client_id=%s&redirect_uri=%s&state=%s",
80 clientID,
81 redirectDomain,
82 encodedState,
83 )
84 return c.Redirect(http.StatusFound, oauthURL)
85}
86
87type Sessions struct {
88 *db.Sessions
89}
90
91// OAuthCallback handles the callback response from Github OAuth
92func OAuthCallback(c echo.Context, a *server.App) error {
93 redirectPath := a.Config.Admin.Path
94
95 accessToken, encodedState, err := parseQueryParams(c)
96 if err != nil {
97 return c.String(http.StatusBadRequest, err.Error())
98 }
99
100 // update the struct with the new access token
101 a.DBTables.Sessions.AccessToken = accessToken
102
103 // Compare the state parameter with the redirect domain
104 redirectDomain := a.GenerateOauthCallbackURL()
105 encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
106 if encodedState != encodedRedirectDomain {
107 return c.String(http.StatusBadRequest, "invalid state parameter")
108 }
109
110 sess, err := getSession(c)
111 if err != nil {
112 return err
113 }
114
115 browserInfo := c.Request().UserAgent()
116 userInfo, err := githubGetUserDetails(c, a)
117 if err != nil {
118 return err
119 }
120
121 user, err := handleUser(c, a, accessToken, browserInfo, userInfo)
122 if err != nil {
123 return err
124 }
125
126 err = setSessionValues(c, sess, user, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID)
127 if err != nil {
128 return err
129 }
130
131 return c.Redirect(http.StatusFound, redirectPath)
132}
133
134// parseQueryParams parses the query parameters from the callback URL
135func parseQueryParams(c echo.Context) (string, string, error) {
136 accessToken := c.QueryParam("access_token")
137 encodedState := c.QueryParam("state")
138
139 _, err := base64.StdEncoding.DecodeString(encodedState)
140 if err != nil {
141 return "", "", fmt.Errorf("invalid state parameter")
142 }
143
144 return accessToken, encodedState, nil
145}
146
147// getSession gets the session from the context
148func getSession(c echo.Context) (*sessions.Session, error) {
149 sess, err := session.Get("session", c)
150 if err != nil {
151 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Could not get session")
152 }
153 return sess, nil
154}
155
156// handleUser handles the user creation or update
157func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
158 userExists, err := queries.CheckDBUserExists(a)
159 if err != nil {
160 return nil, fmt.Errorf("could not check if user exists: %w", err)
161 }
162
163 if !userExists {
164 // if it is a new user creation we compare the gordon token
165 err := CompareGordonToken(c, a)
166 if err != nil {
167 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is empty or not valid")
168 }
169
170 return createUser(a, accessToken, browserInfo, userInfo)
171 }
172
173 if userExists {
174 // if it is an existing user we compare the login and email to see if it is the same user
175 isGoodUser, err := queries.CheckDBUserIsGood(a, userInfo)
176 if err != nil {
177 return nil, fmt.Errorf("could not check if user is good: %w", err)
178 }
179
180 if !isGoodUser {
181 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
182 }
183
184 if isGoodUser {
185 // Update the user
186 updatedUser, err := updateUser(a, accessToken, browserInfo, userInfo)
187 if err != nil {
188 return nil, fmt.Errorf("could not update user: %w", err)
189 }
190 return updatedUser, nil
191 }
192 }
193
194 return nil, fmt.Errorf("could not handle user")
195}
196
197// createUser creates a new user in the database
198func createUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
199 err := queries.CreateUser(a, accessToken, browserInfo, userInfo)
200 if err != nil {
201 return nil, fmt.Errorf("could not create user: %w", err)
202 }
203 return &a.DBTables.User, nil
204}
205
206// updateUser updates an existing user in the database
207func updateUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
208 user, err := queries.UpdateUser(a, accessToken, browserInfo, userInfo)
209 if err != nil {
210 return nil, fmt.Errorf("could not update user: %w", err)
211 }
212 return user, nil
213}
214
215// setSessionValues sets the session values for the user
216func setSessionValues(c echo.Context, sess *sessions.Session, user *db.User, accountID string, expires string, sessionID string) error {
217 sess.Values["authenticated"] = true
218 sess.Values["accountID"] = accountID
219 sess.Values["expires"] = expires
220 sess.Values["sessionID"] = sessionID
221
222 if err := sess.Save(c.Request(), c.Response()); err != nil {
223 return fmt.Errorf("could not save session: %w", err)
224 }
225 return nil
226}
227
228// fetchGithubAPI fetches the Github API
229func fetchGithubAPI(url string, authHeader string, result interface{}) error {
230 req, err := http.NewRequest("GET", url, nil)
231 if err != nil {
232 return err
233 }
234
235 req.Header.Set("Authorization", authHeader)
236
237 resp, err := http.DefaultClient.Do(req)
238 if err != nil {
239 return err
240 }
241 defer resp.Body.Close()
242
243 if resp.StatusCode != http.StatusOK {
244 return fmt.Errorf("got status code %d", resp.StatusCode)
245 }
246
247 body, err := io.ReadAll(resp.Body)
248 if err != nil {
249 return err
250 }
251
252 err = json.Unmarshal(body, &result)
253 if err != nil {
254 return err
255 }
256
257 return nil
258}
259
260// githubGetUserDetails gets the user details from the Github API using the access token
261func githubGetUserDetails(c echo.Context, a *server.App) (userInfo *queries.GithubUserInfo, err error) {
262 accessToken := c.QueryParam("access_token")
263
264 // Fetch user info
265 err = fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo)
266 if err != nil {
267 return nil, err
268 }
269 // Fetch user emails
270 var emailObjects []struct {
271 Email string `json:"email"`
272 }
273 err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects)
274 if err != nil {
275 return nil, err
276 }
277
278 // Extract emails and populate the Emails field in userInfo
279 for _, emailObj := range emailObjects {
280 userInfo.Emails = append(userInfo.Emails, emailObj.Email)
281 }
282
283 return userInfo, nil
284}