Skip to content

Commit

Permalink
Merge pull request #62 from kjsanger/feature/oidc-irods-auth
Browse files Browse the repository at this point in the history
Add OIDC-based iRODS authentication
  • Loading branch information
jmtcsngr authored Sep 10, 2024
2 parents 3720306 + aabc9ba commit bc9d302
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 115 deletions.
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
.DS_store

# Local environment
.env

# IntelliJ / GoLand
.idea/

# Local environment
.env

# Local outputs
build/
*.log
Expand Down
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# syntax = docker/dockerfile:1.2

FROM golang:1.22 AS builder

Expand Down
18 changes: 15 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
version: "3"

services:
irods-server:
container_name: irods-server
image: "ghcr.io/wtsi-npg/ub-16.04-irods-4.2.7:latest"
platform: linux/amd64
ports:
- "127.0.0.1:1247:1247"
- "127.0.0.1:20000-20199:20000-20199"
Expand All @@ -25,9 +25,21 @@ services:
"--cert-file", "/app/config/localhost.crt",
"--key-file", "/app/config/localhost.key",
"--irods-env", "/app/config/app_irods_environment.json",
"--enable-oidc",
"--log-level", "trace"]
environment:
IRODS_PASSWORD: "irods" # Required when the app auth file is not present
# Set the following environment variables in a .env file (files named .env
# are declared in .gitignore):
#
# If no iRODS auth file is provided:
#
# IRODS_PASSWORD
#
# And if using OIDC:
#
# OIDC_CLIENT_ID
# OIDC_CLIENT_SECRET
# OIDC_ISSUER_URL
env_file: .env
ports:
- "3333:3333"
volumes:
Expand Down
76 changes: 72 additions & 4 deletions server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import (
"time"

"github.com/coreos/go-oidc/v3/oidc"
ifs "github.com/cyverse/go-irodsclient/fs"
"github.com/cyverse/go-irodsclient/irods/types"
"github.com/rs/xid"
"github.com/rs/zerolog/hlog"
)
Expand All @@ -49,7 +51,12 @@ func HandleHomePage(server *SqyrrlServer) http.Handler {
requestMethod := r.Method

if requestPath != "/" && requestMethod == "GET" {
redirect := path.Join(EndPointIRODS, requestPath)
if requestPath == "/favicon.ico" {
writeErrorResponse(logger, w, http.StatusNotFound)
return
}

redirect := path.Join(EndpointIRODS, requestPath)
logger.Trace().
Str("from", requestPath).
Str("to", redirect).
Expand Down Expand Up @@ -77,8 +84,8 @@ func HandleHomePage(server *SqyrrlServer) http.Handler {
}

data := pageData{
LoginURL: EndPointLogin,
LogoutURL: EndPointLogout,
LoginURL: EndpointLogin,
LogoutURL: EndpointLogout,
AuthAvailable: server.sqyrrlConfig.EnableOIDC,
Authenticated: server.isAuthenticated(r),
UserName: server.getSessionUserName(r),
Expand Down Expand Up @@ -278,7 +285,68 @@ func HandleIRODSGet(server *SqyrrlServer) http.Handler {
objPath := path.Clean(path.Join("/", r.URL.Path))
logger.Debug().Str("path", objPath).Msg("Getting iRODS data object")

getFileRange(rodsLogger, w, r, server.iRODSAccount, objPath)
var userID string
if server.isAuthenticated(r) {
userID = iRODSUserIDFromEmail(logger, server.getSessionUserEmail(r))
} else {
userID = IRODSPublicUser
}

userZone := server.iRODSAccount.ClientZone

var err error
var rodsFs *ifs.FileSystem
if rodsFs, err = ifs.NewFileSystemWithDefault(server.iRODSAccount,
AppName); err != nil {
logger.Err(err).Msg("Failed to create an iRODS file system")
writeErrorResponse(logger, w, http.StatusInternalServerError)
return
}
defer rodsFs.Release()

// Don't use filesystem.ExistsFile(objPath) here because it will return false if
// the file _does_ exist on the iRODS server, but the server is down or unreachable.
//
// filesystem.StatFile(objPath) is better because we can check for the error type.
if _, err = rodsFs.Stat(objPath); err != nil {
if types.IsAuthError(err) {
logger.Err(err).
Str("path", objPath).
Msg("Failed to authenticate with iRODS")
writeErrorResponse(logger, w, http.StatusUnauthorized)
return
}
if types.IsFileNotFoundError(err) {
logger.Info().
Str("path", objPath).
Msg("Requested path does not exist")
writeErrorResponse(logger, w, http.StatusNotFound)
return
}
logger.Err(err).Str("path", objPath).Msg("Failed to stat file")
writeErrorResponse(logger, w, http.StatusInternalServerError)
return
}

var isReadable bool
isReadable, err = isReadableByUser(logger, rodsFs, userZone, userID, objPath)
if err != nil {
logger.Err(err).Msg("Failed to check if the object is readable")
writeErrorResponse(logger, w, http.StatusInternalServerError)
return
}

if !isReadable {
logger.Info().
Str("path", objPath).
Str("id", userID).
Str("zone", userZone).
Msg("Requested path is not readable by this user")
writeErrorResponse(logger, w, http.StatusForbidden)
return
}

getFileRange(rodsLogger, w, r, rodsFs, objPath)
})
}

Expand Down
19 changes: 10 additions & 9 deletions server/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,14 @@ var _ = Describe("iRODS Get Handler", func() {
When("a non-existent path is given", func() {
var r *http.Request
var handler http.Handler
var err error

BeforeEach(func() {
handler = http.StripPrefix(server.EndpointAPI,
server.HandleIRODSGet(testServer))
handler, err = testServer.GetHandler(server.EndpointIRODS)
Expect(err).NotTo(HaveOccurred())

objPath := path.Join(workColl, "no", "such", "file.txt")
getURL, err := url.JoinPath(server.EndpointAPI, objPath)
getURL, err := url.JoinPath(server.EndpointIRODS, objPath)
Expect(err).NotTo(HaveOccurred())

r, err = http.NewRequest("GET", getURL, nil)
Expand All @@ -106,13 +107,14 @@ var _ = Describe("iRODS Get Handler", func() {
When("a valid data object path is given", func() {
var r *http.Request
var handler http.Handler
var err error

BeforeEach(func() {
handler = http.StripPrefix(server.EndpointAPI,
server.HandleIRODSGet(testServer))
handler, err = testServer.GetHandler(server.EndpointIRODS)
Expect(err).NotTo(HaveOccurred())

objPath := path.Join(workColl, testFile)
getURL, err := url.JoinPath(server.EndpointAPI, objPath)
getURL, err := url.JoinPath(server.EndpointIRODS, objPath)
Expect(err).NotTo(HaveOccurred())

r, err = http.NewRequest("GET", getURL, nil)
Expand All @@ -131,11 +133,10 @@ var _ = Describe("iRODS Get Handler", func() {
When("the data object has public read permissions", func() {
var conn *connection.IRODSConnection
var acl []*types.IRODSAccess
var err error

BeforeEach(func() {
handler = http.StripPrefix(server.EndpointAPI,
server.HandleIRODSGet(testServer))
handler, err = testServer.GetHandler(server.EndpointIRODS)
Expect(err).NotTo(HaveOccurred())

conn, err = irodsFS.GetIOConnection()
Expect(err).NotTo(HaveOccurred())
Expand Down
96 changes: 25 additions & 71 deletions server/irods.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
"path/filepath"
"time"

"github.com/cyverse/go-irodsclient/fs"
ifs "github.com/cyverse/go-irodsclient/fs"
"github.com/cyverse/go-irodsclient/icommands"
"github.com/cyverse/go-irodsclient/irods/types"
"github.com/cyverse/go-irodsclient/irods/util"
Expand Down Expand Up @@ -175,14 +175,14 @@ func NewIRODSAccount(logger zerolog.Logger,

// Before returning the account, check that it is usable by connecting to the
// iRODS server and accessing the root collection.
var filesystem *fs.FileSystem
filesystem, err = fs.NewFileSystemWithDefault(account, AppName)
var filesystem *ifs.FileSystem
filesystem, err = ifs.NewFileSystemWithDefault(account, AppName)
if err != nil {
logger.Err(err).Msg("Failed to create an iRODS file system")
return nil, err
}

var root *fs.Entry
var root *ifs.Entry
root, err = filesystem.StatDir("/")
if err != nil {
logger.Err(err).Msg("Failed to stat the root zone collection")
Expand All @@ -195,15 +195,15 @@ func NewIRODSAccount(logger zerolog.Logger,
return account, err
}

// isPublicReadable checks if the data object at the given path is readable by the
// public user of the zone hosting the file.
// isReadableByUser checks if the data object at the given path is readable by the
// given user in the zone hosting the file.
//
// If iRODS is federated, there may be multiple zones, each with their own public user.
// The zone argument is the zone of public user whose read permission is to be checked,
// If iRODS is federated, there may be multiple zones, each with their own users.
// The zone argument is the zone of user whose read permission is to be checked,
// which is normally the current zone. This is consulted only if the ACL user zone is
// empty.
func isPublicReadable(logger zerolog.Logger, filesystem *fs.FileSystem,
userZone string, rodsPath string) (_ bool, err error) {
func isReadableByUser(logger zerolog.Logger, filesystem *ifs.FileSystem,
userZone string, userName string, rodsPath string) (_ bool, err error) {
var acl []*types.IRODSAccess
var pathZone string

Expand All @@ -224,80 +224,34 @@ func isPublicReadable(logger zerolog.Logger, filesystem *fs.FileSystem,
}

if effectiveUserZone == pathZone &&
ac.UserName == IRODSPublicUser &&
ac.UserName == userName &&
ac.AccessLevel == types.IRODSAccessLevelReadObject {
logger.Trace().
Str("path", rodsPath).
Msg("Public read access found")
Str("user", userName).
Str("zone", userZone).
Msg("Read access found")

return true, nil
}
}

logger.Trace().Str("path", rodsPath).Msg("Public read access not found")
logger.Trace().
Str("path", rodsPath).
Str("user", userName).
Str("zone", userZone).
Msg("Read access not found")

return false, nil
}

// getFileRange serves a file from iRODS to the client. It delegates to http.ServeContent
// which sets the appropriate headers, including Content-Type.
func getFileRange(logger zerolog.Logger, w http.ResponseWriter, r *http.Request,
account *types.IRODSAccount, rodsPath string) {

// TODO: filesystem is thread safe, so it can be shared across requests
var rodsFs *fs.FileSystem
rodsFs *ifs.FileSystem, rodsPath string) {
var err error
if rodsFs, err = fs.NewFileSystemWithDefault(account, AppName); err != nil {
logger.Err(err).Msg("Failed to create an iRODS file system")
writeErrorResponse(logger, w, http.StatusInternalServerError)
return
}

defer rodsFs.Release()

// Don't use filesystem.ExistsFile(objPath) here because it will return false if the
// file _does_ exist on the iRODS server, but the server is down or unreachable.
//
// filesystem.StatFile(objPath) is better because we can check for the error type.
if _, err = rodsFs.StatFile(rodsPath); err != nil {
if types.IsAuthError(err) {
logger.Err(err).
Str("path", rodsPath).
Msg("Failed to authenticate with iRODS")
writeErrorResponse(logger, w, http.StatusUnauthorized)
return
}
if types.IsFileNotFoundError(err) {
logger.Info().
Str("path", rodsPath).
Msg("Requested path does not exist")
writeErrorResponse(logger, w, http.StatusNotFound)
return
}
logger.Err(err).Str("path", rodsPath).Msg("Failed to stat file")
writeErrorResponse(logger, w, http.StatusInternalServerError)
return
}

zone := account.ClientZone

var publicReadable bool
if publicReadable, err = isPublicReadable(logger, rodsFs, zone, rodsPath); err != nil {
logger.Err(err).
Str("path", rodsPath).
Msg("Failed to check public read access")
writeErrorResponse(logger, w, http.StatusInternalServerError)
return
}
if !publicReadable {
logger.Info().
Str("path", rodsPath).
Msg("Requested path is not public readable")
writeErrorResponse(logger, w, http.StatusForbidden)
return
}

var fh *fs.FileHandle
var fh *ifs.FileHandle
if fh, err = rodsFs.OpenFile(rodsPath, "", "r"); err != nil {
logger.Err(err).
Str("path", rodsPath).
Expand All @@ -306,8 +260,8 @@ func getFileRange(logger zerolog.Logger, w http.ResponseWriter, r *http.Request,
return
}

defer func(fh *fs.FileHandle) {
if err = fh.Close(); err != nil {
defer func(fh *ifs.FileHandle) {
if err := fh.Close(); err != nil {
logger.Err(err).
Str("path", rodsPath).
Msg("Failed to close file handle")
Expand All @@ -325,10 +279,10 @@ func getFileRange(logger zerolog.Logger, w http.ResponseWriter, r *http.Request,
// findItems runs a metadata query against iRODS to find any items that have metadata
// with the key sqyrrl::index and value 1. The items are grouped by the value of the
// metadata.
func findItems(filesystem *fs.FileSystem) (items []Item, err error) { // NRV
func findItems(filesystem *ifs.FileSystem) (items []Item, err error) { // NRV
filesystem.ClearCache() // Clears all caches (entries, metadata, ACLs)

var entries []*fs.Entry
var entries []*ifs.Entry
if entries, err = filesystem.SearchByMeta(IndexAttr, IndexValue); err != nil {
return nil, err
}
Expand Down
Loading

0 comments on commit bc9d302

Please sign in to comment.