Skip to content

Commit bb5e10d

Browse files
Merge pull request #2 from joshsoftware/feat/login-service
Implement login service
2 parents fb990e3 + eca224f commit bb5e10d

File tree

23 files changed

+1254
-0
lines changed

23 files changed

+1254
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
local.yaml

cmd/main.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"os"
9+
10+
"os/signal"
11+
"syscall"
12+
"time"
13+
14+
"github.com/joshsoftware/code-curiosity-2025/internal/app"
15+
"github.com/joshsoftware/code-curiosity-2025/internal/config"
16+
)
17+
18+
func main() {
19+
ctx := context.Background()
20+
21+
cfg,err := config.LoadAppConfig()
22+
if err != nil {
23+
slog.Error("error loading app config", "error", err)
24+
return
25+
}
26+
27+
28+
db, err := config.InitDataStore(cfg)
29+
if err != nil {
30+
slog.Error("error initializing database", "error", err)
31+
return
32+
}
33+
defer db.Close()
34+
35+
dependencies := app.InitDependencies(db,cfg)
36+
37+
router := app.NewRouter(dependencies)
38+
39+
server := http.Server{
40+
Addr: fmt.Sprintf(":%s", cfg.HTTPServer.Port),
41+
Handler: router,
42+
}
43+
44+
serverRunning := make(chan os.Signal, 1)
45+
46+
signal.Notify(
47+
serverRunning,
48+
syscall.SIGABRT,
49+
syscall.SIGALRM,
50+
syscall.SIGBUS,
51+
syscall.SIGINT,
52+
syscall.SIGTERM,
53+
)
54+
55+
go func() {
56+
slog.Info("server listening at", "port", cfg.HTTPServer.Port)
57+
58+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
59+
slog.Error("server error", "error", err)
60+
serverRunning <- syscall.SIGINT
61+
}
62+
}()
63+
64+
<-serverRunning
65+
66+
slog.Info("shutting down the server")
67+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
68+
defer cancel()
69+
70+
if err := server.Shutdown(ctx); err != nil {
71+
slog.Error("cannot shut HTTP server down gracefully", "error", err)
72+
}
73+
74+
slog.Info("server shutdown successfully")
75+
}

go.mod

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module github.com/joshsoftware/code-curiosity-2025
2+
3+
go 1.23.4
4+
5+
require (
6+
github.com/golang-jwt/jwt/v4 v4.5.2
7+
github.com/ilyakaznacheev/cleanenv v1.5.0
8+
github.com/jmoiron/sqlx v1.4.0
9+
github.com/lib/pq v1.10.9
10+
golang.org/x/oauth2 v0.29.0
11+
)
12+
13+
require (
14+
github.com/BurntSushi/toml v1.2.1 // indirect
15+
github.com/google/go-cmp v0.6.0 // indirect
16+
github.com/joho/godotenv v1.5.1 // indirect
17+
github.com/kr/pretty v0.3.1 // indirect
18+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
19+
gopkg.in/yaml.v3 v3.0.1 // indirect
20+
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
21+
)

go.sum

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3+
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
4+
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
5+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
6+
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
7+
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
8+
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
9+
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
10+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
11+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
12+
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
13+
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
14+
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
15+
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
16+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
17+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
18+
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
19+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
20+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
21+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
22+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
23+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
24+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
25+
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
26+
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
27+
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
28+
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
29+
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
30+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
31+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
32+
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
33+
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
34+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
35+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
36+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
37+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
38+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
39+
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
40+
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=

internal/app/auth/domain.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package auth
2+
3+
import "database/sql"
4+
5+
const (
6+
LoginWithGithubFailed = "LoginWithGithubFailed"
7+
AccessTokenCookieName = "AccessToken"
8+
GitHubOAuthState = "state"
9+
GithubOauthScope = "read:user"
10+
GetUserGithubUrl = "https://api.github.com/user"
11+
GetUserEmailUrl = "https://api.github.com/user/emails"
12+
)
13+
14+
type User struct {
15+
Id int `json:"user_id"`
16+
GithubId int `json:"github_id"`
17+
GithubUsername string `json:"github_username"`
18+
Email string `json:"email"`
19+
AvatarUrl string `json:"avatar_url"`
20+
CurrentBalance int `json:"current_balance"`
21+
CurrentActiveGoalId sql.NullInt64 `json:"current_active_goal_id"`
22+
IsBlocked bool `json:"is_blocked"`
23+
IsAdmin bool `json:"is_admin"`
24+
Password string `json:"password"`
25+
IsDeleted bool `json:"is_deleted"`
26+
DeletedAt sql.NullInt64 `json:"deleted_at"`
27+
CreatedAt int64 `json:"created_at"`
28+
UpdatedAt int64 `json:"updated_at"`
29+
}
30+
31+
type GithubUserResponse struct {
32+
GithubId int `json:"id"`
33+
GithubUsername string `json:"login"`
34+
AvatarUrl string `json:"avatar_url"`
35+
Email string `json:"email"`
36+
IsAdmin bool `json:"is_admin"`
37+
}

internal/app/auth/handler.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net/http"
7+
8+
"github.com/joshsoftware/code-curiosity-2025/internal/config"
9+
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors"
10+
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/response"
11+
)
12+
13+
type handler struct {
14+
authService Service
15+
appConfig config.AppConfig
16+
}
17+
18+
type Handler interface {
19+
GithubOAuthLoginUrl(w http.ResponseWriter, r *http.Request)
20+
GithubOAuthLoginCallback(w http.ResponseWriter, r *http.Request)
21+
GetLoggedInUser(w http.ResponseWriter, r *http.Request)
22+
}
23+
24+
func NewHandler(authService Service, appConfig config.AppConfig) Handler {
25+
return &handler{
26+
authService: authService,
27+
appConfig: appConfig,
28+
}
29+
}
30+
31+
func (h *handler) GithubOAuthLoginUrl(w http.ResponseWriter, r *http.Request) {
32+
ctx := r.Context()
33+
34+
url := h.authService.GithubOAuthLoginUrl(ctx)
35+
36+
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
37+
}
38+
39+
func (h *handler) GithubOAuthLoginCallback(w http.ResponseWriter, r *http.Request) {
40+
ctx := r.Context()
41+
42+
code := r.URL.Query().Get("code")
43+
44+
token, err := h.authService.GithubOAuthLoginCallback(ctx, code)
45+
if err != nil {
46+
slog.Error("failed to login with github", "error", err)
47+
http.Redirect(w, r, fmt.Sprintf("%s?authError=%s", h.appConfig.ClientURL, LoginWithGithubFailed), http.StatusTemporaryRedirect)
48+
return
49+
}
50+
51+
cookie := &http.Cookie{
52+
Name: AccessTokenCookieName,
53+
Value: token,
54+
//TODO set domain before deploying to production
55+
// Domain: "yourdomain.com",
56+
HttpOnly: true,
57+
}
58+
http.SetCookie(w, cookie)
59+
http.Redirect(w, r, h.appConfig.ClientURL, http.StatusPermanentRedirect)
60+
}
61+
62+
func (h *handler) GetLoggedInUser(w http.ResponseWriter, r *http.Request) {
63+
ctx := r.Context()
64+
65+
userInfo, err := h.authService.GetLoggedInUser(ctx)
66+
if err != nil {
67+
slog.Error("error getting logged in user")
68+
status, errorMessage := apperrors.MapError(err)
69+
response.WriteJson(w, status, errorMessage, nil)
70+
return
71+
}
72+
73+
response.WriteJson(w, http.StatusOK, "logged in user fetched successfully", userInfo)
74+
}

internal/app/auth/service.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log/slog"
7+
8+
"github.com/joshsoftware/code-curiosity-2025/internal/app/user"
9+
"github.com/joshsoftware/code-curiosity-2025/internal/config"
10+
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors"
11+
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/jwt"
12+
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware"
13+
"golang.org/x/oauth2"
14+
"golang.org/x/oauth2/github"
15+
)
16+
17+
type service struct {
18+
githubOAuth2 oauth2.Config
19+
userService user.Service
20+
appCfg config.AppConfig
21+
}
22+
23+
type Service interface {
24+
GithubOAuthLoginUrl(ctx context.Context) string
25+
GithubOAuthLoginCallback(ctx context.Context, code string) (string, error)
26+
GetLoggedInUser(ctx context.Context) (User, error)
27+
}
28+
29+
func NewService(userService user.Service, appCfg config.AppConfig) Service {
30+
oauth2Config := oauth2.Config{
31+
ClientID: appCfg.GithubOauth.ClientID,
32+
ClientSecret: appCfg.GithubOauth.ClientSecret,
33+
RedirectURL: appCfg.GithubOauth.RedirectURL,
34+
Endpoint: github.Endpoint,
35+
Scopes: []string{GithubOauthScope},
36+
}
37+
38+
return &service{
39+
githubOAuth2: oauth2Config,
40+
userService: userService,
41+
appCfg: appCfg,
42+
}
43+
}
44+
45+
func (s *service) GithubOAuthLoginUrl(ctx context.Context) string {
46+
return s.githubOAuth2.AuthCodeURL(GitHubOAuthState, oauth2.AccessTypeOffline)
47+
}
48+
49+
func (s *service) GithubOAuthLoginCallback(ctx context.Context, code string) (string, error) {
50+
token, err := s.githubOAuth2.Exchange(ctx, code)
51+
if err != nil {
52+
slog.Error("failed to exchange token", "error", err)
53+
return "", apperrors.ErrGithubTokenExchangeFailed
54+
}
55+
56+
client := s.githubOAuth2.Client(ctx, token)
57+
resp, err := client.Get(GetUserGithubUrl)
58+
if err != nil {
59+
slog.Error("failed to get user info", "error", err)
60+
return "", apperrors.ErrFailedToGetGithubUser
61+
}
62+
defer resp.Body.Close()
63+
64+
var userInfo GithubUserResponse
65+
err = json.NewDecoder(resp.Body).Decode(&userInfo)
66+
if err != nil {
67+
slog.Error("failed to unmarshal user info", "error", err)
68+
return "", apperrors.ErrInternalServer
69+
}
70+
71+
userData, err := s.userService.GetUserByGithubId(ctx, userInfo.GithubId)
72+
if err != nil {
73+
userData, err = s.userService.CreateUser(ctx, user.CreateUserRequestBody(userInfo))
74+
if err != nil {
75+
slog.Error("failed to create user", "error", err)
76+
return "", apperrors.ErrUserCreationFailed
77+
}
78+
}
79+
80+
jwtToken, err := jwt.GenerateJWT(userData.Id, userInfo.IsAdmin, s.appCfg)
81+
if err != nil {
82+
slog.Error("error generating jwt", "error", err)
83+
return "", apperrors.ErrInternalServer
84+
}
85+
86+
return jwtToken, nil
87+
}
88+
89+
func (s *service) GetLoggedInUser(ctx context.Context) (User, error) {
90+
userIdValue := ctx.Value(middleware.UserIdKey)
91+
92+
userId, ok := userIdValue.(int)
93+
if !ok {
94+
slog.Error("error obtaining user id from context")
95+
return User{}, apperrors.ErrInternalServer
96+
}
97+
98+
user, err := s.userService.GetUserById(ctx, userId)
99+
if err != nil {
100+
slog.Error("failed to get logged in user", "error", err)
101+
return User{}, apperrors.ErrInternalServer
102+
}
103+
104+
return User(user), nil
105+
}

internal/app/dependencies.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package app
2+
3+
import (
4+
"github.com/jmoiron/sqlx"
5+
"github.com/joshsoftware/code-curiosity-2025/internal/app/auth"
6+
"github.com/joshsoftware/code-curiosity-2025/internal/app/user"
7+
"github.com/joshsoftware/code-curiosity-2025/internal/config"
8+
"github.com/joshsoftware/code-curiosity-2025/internal/repository"
9+
)
10+
11+
type Dependencies struct {
12+
AuthService auth.Service
13+
UserService user.Service
14+
AuthHandler auth.Handler
15+
UserHandler user.Handler
16+
AppCfg config.AppConfig
17+
}
18+
19+
func InitDependencies(db *sqlx.DB, appCfg config.AppConfig) Dependencies {
20+
userRepository := repository.NewUserRepository(db)
21+
22+
userService := user.NewService(userRepository)
23+
authService := auth.NewService(userService, appCfg)
24+
25+
authHandler := auth.NewHandler(authService, appCfg)
26+
userHandler := user.NewHandler(userService)
27+
28+
return Dependencies{
29+
AuthService: authService,
30+
UserService: userService,
31+
AuthHandler: authHandler,
32+
UserHandler: userHandler,
33+
AppCfg: appCfg,
34+
}
35+
}

0 commit comments

Comments
 (0)