Skip to content

Commit

Permalink
Allow JWT authentication into legacy APIs (#651)
Browse files Browse the repository at this point in the history
* typo: letter in login page

* httpconfig set defaults for secret key with warn

* allow new authentication in old api

* Updated warn log
  • Loading branch information
fmartingr committed Jul 21, 2023
1 parent 454f217 commit 888d053
Show file tree
Hide file tree
Showing 9 changed files with 48 additions and 467 deletions.
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

0 comments on commit 888d053

Please sign in to comment.