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

Add OIDC-based iRODS authentication #62

Merged
merged 1 commit into from
Sep 10, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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