Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow JWT authentication into legacy APIs #651

Merged
merged 4 commits into from Jul 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 2 additions & 10 deletions internal/cmd/server.go
Expand Up @@ -19,7 +19,7 @@ func newServerCommand() *cobra.Command {
cmd.Flags().IntP("port", "p", 8080, "Port used by the server")
cmd.Flags().StringP("address", "a", "", "Address the server listens to")
cmd.Flags().StringP("webroot", "r", "/", "Root path that used by server")
cmd.Flags().Bool("access-log", true, "Print out a non-standard access log")
cmd.Flags().Bool("access-log", false, "Print out a non-standard access log")
cmd.Flags().Bool("serve-web-ui", true, "Serve static files from the webroot path")
cmd.Flags().String("secret-key", "", "Secret key used for encrypting session data")

Expand All @@ -40,15 +40,7 @@ func newServerCommandHandler() func(cmd *cobra.Command, args []string) {

cfg, dependencies := initShiori(ctx, cmd)

// Check HTTP configuration
// For now it will just log to the console, but in the future it will be fatal. The only required
// setting for now is the secret key.
if errs, isValid := cfg.Http.IsValid(); !isValid {
dependencies.Log.Error("Found some errors in configuration.For now server will start but this will be fatal in the future.")
for _, err := range errs {
dependencies.Log.WithError(err).Error("found invalid configuration")
}
}
cfg.Http.SetDefaults(dependencies.Log)

// Validate root path
if rootPath == "" {
Expand Down
15 changes: 10 additions & 5 deletions internal/config/config.go
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/gofrs/uuid"
"github.com/sethvargo/go-envconfig"
"github.com/sirupsen/logrus"
)
Expand Down Expand Up @@ -78,13 +79,17 @@ type Config struct {
Http *HttpConfig
}

// IsValid checks if the configuration is valid
func (c HttpConfig) IsValid() (errs []error, isValid bool) {
// SetDefaults sets the default values for the configuration
func (c *HttpConfig) SetDefaults(logger *logrus.Logger) {
// Set a random secret key if not set
if c.SecretKey == "" {
errs = append(errs, fmt.Errorf("SHIORI_HTTP_SECRET_KEY is required"))
logger.Warn("SHIORI_HTTP_SECRET_KEY is not set, using random value. This means that all sessions will be invalidated on server restart.")
randomUUID, err := uuid.NewV4()
if err != nil {
logger.WithError(err).Fatal("couldn't generate a random UUID")
}
c.SecretKey = randomUUID.String()
}

return errs, len(errs) == 0
}

// SetDefaults sets the default values for the configuration
Expand Down
2 changes: 1 addition & 1 deletion internal/http/routes/legacy.go
Expand Up @@ -67,7 +67,7 @@ func (r *LegacyAPIRoutes) Setup(g *gin.Engine) {
DataDir: r.cfg.Storage.DataDir,
RootPath: r.cfg.Http.RootPath,
Log: false, // Already done by gin
})
}, r.deps)
r.legacyHandler.PrepareSessionCache()
r.legacyHandler.PrepareTemplates()

Expand Down
2 changes: 1 addition & 1 deletion internal/view/login.html
Expand Up @@ -15,7 +15,7 @@
<link rel="icon" type="image/x-icon" href="assets/res/favicon.ico">

<link href="assets/css/source-sans-pro.min.css" rel="stylesheet">
<link href="assets/css/fontawesome.min.css" rel="stylesheet">w
<link href="assets/css/fontawesome.min.css" rel="stylesheet">
<link href="assets/css/stylesheet.css" rel="stylesheet">

<script src="assets/js/vue.min.js"></script>
Expand Down
95 changes: 0 additions & 95 deletions internal/webserver/handler-api.go
Expand Up @@ -13,12 +13,10 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/go-shiori/shiori/internal/core"
"github.com/go-shiori/shiori/internal/database"
"github.com/go-shiori/shiori/internal/model"
"github.com/gofrs/uuid"
"github.com/julienschmidt/httprouter"
"golang.org/x/crypto/bcrypt"
)
Expand Down Expand Up @@ -48,99 +46,6 @@ func downloadBookmarkContent(book *model.Bookmark, dataDir string, request *http
return &result, err
}

// apiLogin is handler for POST /api/login
func (h *Handler) apiLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()

// Decode request
request := struct {
Username string `json:"username"`
Password string `json:"password"`
Remember bool `json:"remember"`
Owner bool `json:"owner"`
}{}

err := json.NewDecoder(r.Body).Decode(&request)
checkError(err)

// Prepare function to generate session
genSession := func(account model.Account, expTime time.Duration) {
// Create session ID
sessionID, err := uuid.NewV4()
checkError(err)

// Save session ID to cache
strSessionID := sessionID.String()
h.SessionCache.Set(strSessionID, account, expTime)

// Save user's session IDs to cache as well
// useful for mass logout
sessionIDs := []string{strSessionID}
if val, found := h.UserCache.Get(request.Username); found {
sessionIDs = val.([]string)
sessionIDs = append(sessionIDs, strSessionID)
}
h.UserCache.Set(request.Username, sessionIDs, -1)

// Send login result
account.Password = ""
loginResult := struct {
Session string `json:"session"`
Account model.Account `json:"account"`
Expires string `json:"expires"`
}{strSessionID, account, time.Now().UTC().Add(expTime).Format(time.RFC1123)}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&loginResult)
checkError(err)
}

// Check if user's database is empty or there are no owner.
// If yes, and user uses default account, let him in.
searchOptions := database.GetAccountsOptions{
Owner: true,
}

accounts, err := h.DB.GetAccounts(ctx, searchOptions)
checkError(err)

if len(accounts) == 0 && request.Username == "shiori" && request.Password == "gopher" {
genSession(model.Account{
Username: "shiori",
Owner: true,
}, time.Hour)
return
}

// Get account data from database
account, exist, err := h.DB.GetAccount(ctx, request.Username)
checkError(err)

if !exist {
panic(fmt.Errorf("username doesn't exist"))
}

// Compare password with database
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(request.Password))
if err != nil {
panic(fmt.Errorf("username and password don't match"))
}

// If login request is as owner, make sure this account is owner
if request.Owner && !account.Owner {
panic(fmt.Errorf("account level is not sufficient as owner"))
}

// Calculate expiration time
expTime := time.Hour
if request.Remember {
expTime = time.Hour * 24 * 30
}

// Create session
genSession(account, expTime)
}

// ApiLogout is handler for POST /api/logout
func (h *Handler) ApiLogout(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Get session ID
Expand Down
70 changes: 0 additions & 70 deletions internal/webserver/handler-ui.go
Expand Up @@ -20,76 +20,6 @@ import (
"github.com/go-shiori/shiori/internal/model"
)

// serveFile is handler for general file request
func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
rootPath := strings.Trim(h.RootPath, "/")
urlPath := strings.Trim(r.URL.Path, "/")
filePath := strings.TrimPrefix(urlPath, rootPath)
filePath = strings.Trim(filePath, "/")

err := serveFile(w, filePath, true)
checkError(err)
}

// serveJsFile is handler for GET /js/*filepath
func (h *Handler) serveJsFile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
jsFilePath := ps.ByName("filepath")
jsFilePath = path.Join("js", jsFilePath)
jsDir, jsName := path.Split(jsFilePath)

if developmentMode && fp.Ext(jsName) == ".js" && strings.HasSuffix(jsName, ".min.js") {
jsName = strings.TrimSuffix(jsName, ".min.js") + ".js"
tmpPath := path.Join(jsDir, jsName)
if assetExists(tmpPath) {
jsFilePath = tmpPath
}
}

err := serveFile(w, jsFilePath, true)
checkError(err)
}

// serveIndexPage is handler for GET /
func (h *Handler) serveIndexPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
if err != nil {
newPath := path.Join(h.RootPath, "/login")
redirectURL := createRedirectURL(newPath, r.URL.String())
redirectPage(w, r, redirectURL)
return
}

if developmentMode {
if err := h.PrepareTemplates(); err != nil {
log.Printf("error during template preparation: %s", err)
}
}

err = h.templates["index"].Execute(w, h.RootPath)
checkError(err)
}

// serveLoginPage is handler for GET /login
func (h *Handler) serveLoginPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session is not valid
err := h.validateSession(r)
if err == nil {
redirectURL := path.Join(h.RootPath, "/")
redirectPage(w, r, redirectURL)
return
}

if developmentMode {
if err := h.PrepareTemplates(); err != nil {
log.Printf("error during template preparation: %s", err)
}
}

err = h.templates["login"].Execute(w, h.RootPath)
checkError(err)
}

// ServeBookmarkContent is handler for GET /bookmark/:id/content
func (h *Handler) ServeBookmarkContent(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
Expand Down
38 changes: 30 additions & 8 deletions internal/webserver/handler.go
Expand Up @@ -4,11 +4,13 @@ import (
"fmt"
"html/template"
"net/http"
"strings"

"github.com/go-shiori/shiori/internal/config"
"github.com/go-shiori/shiori/internal/database"
"github.com/go-shiori/shiori/internal/model"
"github.com/go-shiori/warc"
cch "github.com/patrickmn/go-cache"
"github.com/sirupsen/logrus"
)

var developmentMode = false
Expand All @@ -23,6 +25,8 @@ type Handler struct {
ArchiveCache *cch.Cache
Log bool

depenencies *config.Dependencies

templates map[string]*template.Template
}

Expand All @@ -46,13 +50,6 @@ func (h *Handler) PrepareSessionCache() {
})
}

func (h *Handler) prepareArchiveCache() {
h.ArchiveCache.OnEvicted(func(key string, data interface{}) {
archive := data.(*warc.Archive)
archive.Close()
})
}

func (h *Handler) PrepareTemplates() error {
// Prepare variables
var err error
Expand Down Expand Up @@ -109,6 +106,31 @@ func (h *Handler) GetSessionID(r *http.Request) string {

// validateSession checks whether user session is still valid or not
func (h *Handler) validateSession(r *http.Request) error {
authorization := r.Header.Get(model.AuthorizationHeader)
if authorization != "" {
authParts := strings.SplitN(authorization, " ", 2)
if len(authParts) != 2 && authParts[0] != model.AuthorizationTokenType {
return fmt.Errorf("session has been expired")
}

account, err := h.depenencies.Domains.Auth.CheckToken(r.Context(), authParts[1])
if err != nil {
return err
}

if r.Method != "" && r.Method != "GET" && !account.Owner {
return fmt.Errorf("account level is not sufficient")
}

h.depenencies.Log.WithFields(logrus.Fields{
"username": account.Username,
"method": r.Method,
"path": r.URL.Path,
}).Info("allowing legacy api access using JWT token")

return nil
}

sessionID := h.GetSessionID(r)
if sessionID == "" {
return fmt.Errorf("session is not exist")
Expand Down