diff --git a/.gitignore b/.gitignore index eedcf4e4..51f80ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .vscode/ .gitattributes -*.code-workspace \ No newline at end of file +*.code-workspace + +# using TestClass.cls for objecscript functionality exploration / convince myself that things work the way I expect them to +cls/SourceControl/Git/TestClass.cls \ No newline at end of file diff --git a/README.md b/README.md index ec99d771..ecd25aed 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,9 @@ Assuming you have the local and remote repositories created, `git config core.sshCommand 'ssh -i ~/.ssh/'` 8. Test the refresh button for the remote branches on the WebUI, fetch from the source control menu in Studio or VS Code, and `git fetch` in Git Bash. All 3 should work without any issues. +### HTTPS Support +We recommend that people connect to their remote git repository using SSH. If you cannot use SSH connections, we also have support for HTTPS connection through OAuth2. See [our documentation for setting up an https connection](/docs/https.md). + ## Support If you find a bug or would like to request an enhancement, [report an issue](https://github.com/intersystems/git-source-control/issues/new). If you have a question, post it on the [InterSystems Developer Community](https://community.intersystems.com/) - consider using the "Git" and "Source Control" tags as appropriate. diff --git a/cls/SourceControl/Git/Extension.cls b/cls/SourceControl/Git/Extension.cls index 77ebe01a..73a673a6 100644 --- a/cls/SourceControl/Git/Extension.cls +++ b/cls/SourceControl/Git/Extension.cls @@ -11,6 +11,7 @@ XData Menu + @@ -193,6 +194,7 @@ Method OnSourceMenuItem(name As %String, ByRef Enabled As %String, ByRef Display set Enabled = $CASE(name, "Status": 1, "GitWebUI" : 1, + "Authenticate":1, "Import": 1, "ImportForce": 1, "NewBranch": BranchLocked, @@ -206,6 +208,7 @@ Method OnSourceMenuItem(name As %String, ByRef Enabled As %String, ByRef Display // cases "Status": 1, "GitWebUI" : 1, + "Authenticate" : 1, "Export": 1, "ExportForce": 1, "Import": 1, diff --git a/cls/SourceControl/Git/OAuth2.cls b/cls/SourceControl/Git/OAuth2.cls new file mode 100644 index 00000000..47c9226d --- /dev/null +++ b/cls/SourceControl/Git/OAuth2.cls @@ -0,0 +1,60 @@ +Include %syPrompt + +IncludeGenerator %syPrompt + +Class SourceControl.Git.OAuth2 Extends %RegisteredObject +{ + +/// GenerateVerifier returns a cryptographically random 32 byte value +ClassMethod GenerateVerifier() As %String +{ + new $NAMESPACE + set $NAMESPACE = "%SYS" + return ##class(%SYSTEM.Encryption).GenCryptRand(32) +} + +/// Builds the authorization code URL for the given configuration +ClassMethod AuthCodeURL(c As SourceControl.Git.OAuth2.Config, namespace As %String, Output state, Output verifier) As %String +{ + set state = namespace_"_"_..GenerateVerifier() + set verifier = ..GenerateVerifier() + set url = c.AuthCodeURL(state, verifier) + return url +} + +ClassMethod GetURLsFromRemote(remote As %String, Output authCodeURL, Output tokenURL) As %Boolean +{ + if remote [ "//github.com/" { + set authCodeURL = "https://github.com/login/oauth/authorize" + set tokenURL = "https://github.com/login/oauth/access_token" + return 1 + } elseif remote [ "gitlab" { + set gitlaburl = $Piece(remote, ".com", 1) _ ".com/" + set authCodeURL = gitlaburl _ "/oauth/authorize" + set tokenUTL = gitlaburl _ "/oauth/token" + return 1 + } else { + return 0 + } +} + +ClassMethod SetRemoteURLWithToken(remote As %String) As %String +{ + set token = ##class(SourceControl.Git.Util.CredentialManager).GetToken($username, .err, .code) + if ('(remote [ token)) { + set post = $piece(remote,"https://",2) + if (post [ "@") { + set post = $piece(post, "@",2) + } + set remote = "https://oauth2:"_token_"@"_post + + } + return remote +} + +ClassMethod GetToken() As %String +{ + return ##class(SourceControl.Git.Util.CredentialManager).GetToken($username, .err, .code) +} + +} diff --git a/cls/SourceControl/Git/OAuth2/Config.cls b/cls/SourceControl/Git/OAuth2/Config.cls new file mode 100644 index 00000000..1c1ec913 --- /dev/null +++ b/cls/SourceControl/Git/OAuth2/Config.cls @@ -0,0 +1,223 @@ +Class SourceControl.Git.OAuth2.Config Extends %Persistent +{ + +/// Name is the identifier for this configuration +Property Name As %String(MAXLEN = 127); + +/// ClientID is the OAuth Application ID. Stored in private memopry store only accessible by user +Property ClientID As %String(MAXLEN = "") [ Transient ]; + +/// ClientSecret is the OAuth Application secret. Stored in private memopry store only accessible by user +Property ClientSecret As %String(MAXLEN = "") [ Transient ]; + +/// Endpoint contains the resource server's token endpoint +Property Endpoint As Endpoint; + +/// RedirectURL is the URL to redirect the auth token +/// to after authenticating with the resource owner +Property RedirectURL As %String(MAXLEN = ""); + +Property state As %String; + +Property verifier As %String; + +/// Scopes specifies the list of scopes we are requesting access to +Property Scopes As %List; + +Property Username As %String; + +Index Username On Username [ IdKey, Unique ]; + +Method ClientIDSet(InputValue As %String) As %Status +{ + set code = "", error = "" + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(..Username,"clientid",InputValue, .error, .code) + if (code '= 1) || (error '= "") { + return $$$ERROR($$$GeneralError,"Set failed with following error: "_error) + } + return $$$OK +} + +Method ClientIDGet() As %String +{ + return ##class(SourceControl.Git.Util.CredentialManager).GetKeyPair(..Username,"clientid", .error, .code) +} + +Method ClientSecretSet(InputValue As %String) As %Status +{ + set code = "", error = "" + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(..Username,"clientsecret",InputValue, .error, .code) + if (code '= 1) || (error '= "") { + return $$$ERROR($$$GeneralError,"Set failed with following error: "_error) + } + return $$$OK +} + +Method ClientSecretGet() As %String +{ + return ##class(SourceControl.Git.Util.CredentialManager).GetKeyPair(..Username,"clientsecret", .error, .code) +} + +ClassMethod GetConfig(username As %String) As SourceControl.Git.OAuth2.Config +{ + set config = ##class(SourceControl.Git.OAuth2.Config).%OpenId(username) + + return config +} + +// TODO: We will need a authStyleCache when we use autodetect for Endpoint.AuthStyle in the future + +Method %OnNew(configName As %String, clientID As %String, clientSecret As %String, authEndpoint As %String, tokenEndpoint As %String, redirectURL As %String, scopes As %List = "") As %Status +{ + set ..Name = configName + set ..Username = $username + set ..ClientID = clientID + set ..ClientSecret = clientSecret + set ..Endpoint = ##class(Endpoint).%New() + set ..Endpoint.AuthURL = authEndpoint + set ..Endpoint.TokenURL = tokenEndpoint + set ..RedirectURL = redirectURL + + + if ('scopes) { + set scopes = $lb("repo") + } + set ..Scopes = scopes + + return $$$OK +} + +Method AuthCodeURL(state As %String, verifier As %String) As %String +{ + #; new $NAMESPACE + #; set $NAMESPACE = "%SYS" + + set params("response_type") = "code" + set params("client_id") = ..ClientID + set:(..RedirectURL '= "") params("redirect_uri") = ..RedirectURL + set:(state '= "") params("state") = state + set:($LISTLENGTH(..Scopes) > 0) params("scope") = $LISTTOSTRING(..Scopes," ") + if verifier { + set code = ##class(%SYSTEM.Encryption).SHAHash(256, verifier) + set params("code_challenge_method") = "S256" + set params("code_challenge") = code + } + + return ..GetURLWithParams(..Endpoint.AuthURL, .params) +} + +Method Exchange(authCode As %String, verifier As %String, Output sc As %Status) As %String +{ + do ##class(%Net.URLParser).Decompose(..Endpoint.TokenURL, .urlComponents) + + set request = ##class(%Net.HttpRequest).%New() + set request.Server = urlComponents("host") + set request.Https = (urlComponents("scheme")="https") + do request.SetParam("grant_type", "authorization_code") + do request.SetParam("code", authCode) + do request.SetParam("code_verifier", verifier) + do:(..ClientID '= "") request.SetParam("client_id", ..ClientID) + do:(..ClientSecret '= "") request.SetParam("client_secret", ..ClientSecret) + // we don't need the redirect_uri parameter because we will be consuming the token here + + // TODO: also add support to put client creds in header instead of params + // either is allowed, will have to try both to see which succeeds + // this is when we will need the `AuthStyle` parameter in the endpoint + do request.SetHeader("Accept", "application/json") + + do ..CreateSSLConfigIfNonExistent("GitExtensionForIris") + + set request.SSLConfiguration = "GitExtensionForIris" + set sc = request.Get(urlComponents("path")) + if sc '= $$$OK { + // something went wrong + return "" + } + + try { + set obj = {}.%FromJSON(request.HttpResponse.Data) + } catch ex { + set sc = ex.AsStatus() + return "" + } + + if obj.%IsDefined("access_token") && (obj.%GetTypeOf("access_token") = "string") { + return obj.%Get("access_token") + } else { + set sc = $$$ERROR($$$GeneralError,"Unable to read access_token from response") + return "" + } +} + +ClassMethod CreateSSLConfigIfNonExistent(name As %String) +{ + do ##class(%zpkg.isc.sc.git.SSLConfig).CreateSSLConfigIfNonExistent(name) +} + +ClassMethod GetURLWithParams(url As %String, ByRef params As %String) As %String +{ + if $find(url, "?") { + set url = url_"&" + } else { + set url = url_"?" + } + + set curParamKey = "" + for { + set isFirstIter = (curParamKey = "") + set curParamKey = $order(params(curParamKey), 1, curParamValue) + + set isLastIter = (curParamKey = "") + set:'(isFirstIter || isLastIter) url = url_"&" + + quit:(isLastIter) + + // TODO-etamarch -> I had issues with $$$URLENCODE, need to look into this later + set url = url_$$$URLENCODE(curParamKey)_"="_$$$URLENCODE(curParamValue) + } + return url +} + +Storage Default +{ + + +%%CLASSNAME + + +ClientID + + +ClientSecret + + +Endpoint + + +RedirectURL + + +Scopes + + +Username + + +state + + +verifier + + +Name + + +^SourceControl.Git.O7826.ConfigD +ConfigDefaultData +^SourceControl.Git.O7826.ConfigD +^SourceControl.Git.O7826.ConfigI +^SourceControl.Git.O7826.ConfigS +%Storage.Persistent +} + +} diff --git a/cls/SourceControl/Git/OAuth2/Endpoint.cls b/cls/SourceControl/Git/OAuth2/Endpoint.cls new file mode 100644 index 00000000..a491fda4 --- /dev/null +++ b/cls/SourceControl/Git/OAuth2/Endpoint.cls @@ -0,0 +1,30 @@ +Class SourceControl.Git.OAuth2.Endpoint Extends %SerialObject +{ + +Property AuthURL As %String; + +Property DeviceAuthURL As %String; + +Property TokenURL As %String; + +// TODO: Might also need a Property to describe the auth style (i.e either in the header, or in the body) + +Storage Default +{ + + +AuthURL + + +DeviceAuthURL + + +TokenURL + + +EndpointState +^SourceControl.Git7826.EndpointS +%Storage.Serial +} + +} diff --git a/cls/SourceControl/Git/Settings.cls b/cls/SourceControl/Git/Settings.cls index e33809bd..cddfda58 100644 --- a/cls/SourceControl/Git/Settings.cls +++ b/cls/SourceControl/Git/Settings.cls @@ -35,6 +35,12 @@ Property gitUserName As %String(MAXLEN = 255) [ InitialExpression = {##class(Sou /// Attribution: Email address for user ${username} Property gitUserEmail As %String(MAXLEN = 255) [ InitialExpression = {##class(SourceControl.Git.Utils).GitUserEmail()} ]; +/// URL for git remote +Property gitRemoteURL As %String(MAXLEN = "") [ InitialExpression = {##class(SourceControl.Git.Utils).GetConfiguredRemote()} ]; + +/// Type of git remote (SSH or HTTPS (Only with OAuth)) +Property gitRemoteType As %String(VALUELIST = ",HTTPS,SSH") [ InitialExpression = {##class(SourceControl.Git.Settings).GetRemoteType(##class(SourceControl.Git.Utils).GetConfiguredRemote())} ]; + /// Whether mapped items should be read-only, preventing them from being added to source control Property mappedItemsReadOnly As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).MappedItemsReadOnly()} ]; @@ -156,6 +162,8 @@ Method %Save() As %Status set @storage@("settings", "warnInstanceWideUncommitted") = ..warnInstanceWideUncommitted set @storage@("settings", "basicMode") = ..systemBasicMode set @storage@("settings", "environmentName") = ..environmentName + set @storage@("settings","gitRemoteType") = ..gitRemoteType + set @storage@("settings","gitRemoteURL") = ..gitRemoteURL set @storage@("settings", "lockBranch") = ..lockBranch if ..basicMode = "system" { kill @storage@("settings", "user", $username, "basicMode") @@ -303,6 +311,8 @@ ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator ] do %code.WriteLine(" set list(4) = ""FAILOVER""") do %code.WriteLine(" set list(5) = """"") do %code.WriteLine(" set response = ##class(%Library.Prompt).GetArray("_promptQuoted_",.value,.list,,,,"_defaultPromptFlag_")") + } elseif ((propertyDef) && (propertyDef.Name ="gitRemoteType")) { + do %code.WriteLine(" if (inst.gitRemoteURL '= """") { set value = inst.GetRemoteType(inst.gitRemoteURL)}") } else { do %code.WriteLine(" set response = ##class(%Library.Prompt).GetString("_promptQuoted_",.value,,,,"_defaultPromptFlag_")") } @@ -439,14 +449,45 @@ Method OnAfterConfigure() As %Boolean $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Init")) $$$ThrowOnError(workMgr.WaitForComplete()) } elseif (value = 2) { - set response = ##class(%Library.Prompt).GetString("Git remote URL (note: if authentication is required, use SSH, not HTTPS):",.remote,,,,defaultPromptFlag) + set remote = $select(..gitRemoteURL:..gitRemoteURL, 1:"") + set response = ##class(%Library.Prompt).GetString("Git remote URL:",.remote,,,,defaultPromptFlag) if (response '= $$$SuccessResponse) { quit } if (remote = "") { quit } - // using work queue manager ensures proper OS user context/file ownership + set ..gitRemoteURL = remote + set ..gitRemoteType = ..GetRemoteType(..gitRemoteURL) + do ..%Save() + + if ..gitRemoteType = "HTTPS" { + Write !, "Please navigate to the Embedded Git UI on your browser, and press ""Authenticate"" in the bottom left corner." + Write !, "Once that process is complete, return here to verify cloning was successful" + Write !, "*Note: You must log in to the Management Portal as the current user" + + // poll attempt count + set try = 0 + // poll every `SLEEPTIME` seconds + set SLEEPTIME = 5 + // stop polling after `TIMEOUT` seconds + set TIMEOUT = 300 + While try*SLEEPTIME < TIMEOUT { + do ##class(SourceControl.Git.Util.CredentialManager).GetToken($username, .err, .code) + if code '= 1 { + Write "Unable to query credential manager" + // something went wrong, return from method + return + } + + if err = "" { + // token was saved successfully, exit loop + quit + } + } + } + + // using work queue manager ensures proper OS user context/file ownership set workMgr = $System.WorkMgr.%New("") $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Clone",remote)) $$$ThrowOnError(workMgr.WaitForComplete()) @@ -518,4 +559,9 @@ Method SaveDefaults() As %Boolean return ##class(%zpkg.isc.sc.git.Defaults).SetDefaultSettings(defaults) } +ClassMethod GetRemoteType(remoteURL As %String) As %String +{ + return $select(remoteURL [ "https": "HTTPS",1:"SSH") +} + } diff --git a/cls/SourceControl/Git/Util/CredentialManager.cls b/cls/SourceControl/Git/Util/CredentialManager.cls new file mode 100644 index 00000000..388970aa --- /dev/null +++ b/cls/SourceControl/Git/Util/CredentialManager.cls @@ -0,0 +1,235 @@ +Class SourceControl.Git.Util.CredentialManager Extends %RegisteredObject +{ + +/// Description +Property pvtStore [ Internal, Private ]; + +ClassMethod Test() [ Private ] +{ + Do ##class(SourceControl.Git.Util.CredentialManager).Stop() + + set username = "testUser" + w "Getting token for user """_username_""", expect error", ! + set token = ..GetToken(username, .err, .code) + zw token, err, code + + set token = "testToken" + w !, "Setting token """_token_""" for user """_username_"""", ! + do ..SetToken(username, token, .err, .code) + zw err, code + + w !, "Getting token for user "_username_", expect to get token """_token_"""", ! + set token = ..GetToken(username, .err, .code) + zw token, err, code +} + +ClassMethod Test2() [ Private ] +{ + set username = "testUser" + set token = ..GetToken(username, .err, .code) + zw token, err, code +} + +/// Creates the `..GetEventName()` named event +/// Waits on signals and services request +Method Run() [ Private ] +{ + do ##class(%SYSTEM.Event).Create(..GetEventName()) + + set i%pvtStore = ##class(PrivateMemoryStore).%New() + set code = 0 + while (code '= -1) { + try { + set code = ..Wait(.msgType, .msgContent) + if (code = 1) { + do ..HandleMessage(msgType, msgContent) + } + } catch err { + do err.Log() + } + } +} + +/// GetToken is used to retreive the access token for a particular git user +/// gitUsername (optional) default: `""` -- username for which token is to be retreived +/// error -- pass by reference -- returns error: if error '= "" we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +/// Returns fetched token +ClassMethod GetToken(gitUsername As %String = "", Output error As %String, Output code As %String) As %String +{ + set $lb(token, error) = ..Signal("GET", $lb($JOB, gitUsername), .code) + return token +} + +/// SetToken is used to set the access token for a particular git user +/// gitUsername (optional) default: `""` -- username for which token is to be set +/// error -- pass by reference -- returns error: if (error '= "") we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +ClassMethod SetToken(gitUsername As %String = "", gitToken As %String, Output error As %String, Output code) +{ + set $lb(, error) = ..Signal("SET", $lb($JOB, gitUsername, gitToken), .code) +} + +ClassMethod SendResponse(toPID As %Integer, message As %String, error As %String) [ Private ] +{ + if $System.Event.Signal(toPID, $lb(message, error)) '= 1 { + #; do ..LogForDaemon("Unable to send message: """_message_""" to: "_toPID) + } +} + +/// SetKeyPair is used to set the value for a key-value pair for a particular git user +/// gitUsername default: `""` -- username for which value is to be set +/// error -- pass by reference -- returns error: if (error '= "") we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +ClassMethod SetKeyPair(gitUsername As %String = "", key As %String, value As %String, Output error As %String, Output code) +{ + if (gitUsername = "") { + do ..SendResponse($JOB, "", "provide username") + q + } + set $lb(,error) = ..Signal("SET",$lb($JOB,gitUsername_"-"_key,value), .code) +} + +/// GetKeyPair is used to retreive the value for a key for a particular git user +/// gitUsername default: `""` -- username for which the value is to be retreived +/// error -- pass by reference -- returns error: if error '= "" we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +/// Returns fetched value +ClassMethod GetKeyPair(gitUsername As %String = "", key As %String, Output error As %String, Output code) As %String +{ + set $lb(value, error) = ..Signal("GET", $lb($JOB, gitUsername_"-"_key), .code) + return value +} + +Method HandleMessage(msgType As %String, msgContent As %String) [ Private ] +{ + try { + // make sure the message is appropriately formatted + set $lb(senderPID, gitUsername, gitToken) = msgContent + } catch err { + do err.Log() + quit + } + + if '$data(senderPID) { + #; do ..LogForDaemon("No source PID provided") + quit + } + + if '$data(gitUsername) { + do ..SendResponse(senderPID, "", "provide username") + quit + } + + + + set irisUsername = ##class(%SYS.ProcessQuery).%OpenId(senderPID).UserName + // key that the token would be mapped from + set key = $lb(irisUsername, gitUsername) + if msgType = "GET" { + if i%pvtStore.KeyExists(key) { + do ..SendResponse(senderPID, i%pvtStore.Retrieve(key), "") + } else { + do ..SendResponse(senderPID, "", "key does not exist") + } + } elseif msgType = "SET" { + if '$data(gitToken) { + do ..SendResponse(senderPID, "", "provide git token") + quit + } + do i%pvtStore.Store(key, gitToken) + do ..SendResponse(senderPID, gitToken, "") + } +} + +ClassMethod Signal(msgType As %String, msgContent As %String, Output responseCode) As %String [ Private ] +{ + // Make sure the daemon is running + do ..Start() + + // Clear any pending messages for this process' resource + do $System.Event.Clear($Job) + + // Signal the daemon + do ##class(%SYSTEM.Event).Signal(..GetEventName(),$ListBuild(msgType,msgContent)) + set $listbuild(responseCode,msg) = $System.Event.WaitMsg("",5) + return msg +} + +Method Wait(Output msgType As %String, Output msgContent As %String) As %Integer +{ + set (msg,msgType,msgContent) = "" + set $listbuild(code,msg) = ##class(%SYSTEM.Event).WaitMsg(..GetEventName(),1) + if $listvalid(msg) { + set $listbuild(msgType,msgContent) = msg + } + return code +} + +ClassMethod GetEventName() As %String [ Private ] +{ + return $Name(^isc.git.sc("Daemon")) //^"_$classname() +} + +ClassMethod Start() [ Private ] +{ + if ..CheckStatus() { + quit + } + job ..StartInternal():(:::1):5 + if ('$test) { + $$$ThrowStatus($$$ERROR($$$GeneralError,"Daemon process failed to start")) + } + while '$System.Event.Defined(..GetEventName()) { + hang 1 + if $increment(wait) > 5 { + // this is a no-no situation, right? + // we would never want to return from Start without starting + quit + } + } +} + +/// TODO-etamarch -> This was Private, but we cannot have jobs call private methods. Need to find a structural workaround +ClassMethod StartInternal() +{ + try { + set lock = $System.AutoLock.Lock(..GetEventName(), , 2) + set daemon = ..%New() + do daemon.Run() + } catch err { + #; do LogForDaemon(err.DisplayString()) + } +} + +ClassMethod Stop() [ Private ] +{ + do ##class(%SYSTEM.Event).Delete(..GetEventName()) + set pid = ^$LOCK(..GetEventName(), "OWNER") + if (pid > 0) { + do $System.Process.Terminate(pid) + } +} + +ClassMethod Restart() [ Private ] +{ + do ..Stop() + do ..Start() +} + +ClassMethod CheckStatus() As %Boolean [ Private ] +{ + return ($data(^$LOCK(..GetEventName())) = 10) +} + +/// This callback method is invoked by the %Close method to +/// provide notification that the current object is being closed. +/// +///

The return value of this method is ignored. +Method %OnClose() As %Status [ Private, ServerOnly = 1 ] +{ + do ##class(%SYSTEM.Event).Delete(..GetEventName()) + return $$$OK +} + +} diff --git a/cls/SourceControl/Git/Util/PrivateMemoryStore.cls b/cls/SourceControl/Git/Util/PrivateMemoryStore.cls new file mode 100644 index 00000000..e13e6666 --- /dev/null +++ b/cls/SourceControl/Git/Util/PrivateMemoryStore.cls @@ -0,0 +1,193 @@ +/// Key-value store where values are stored in private memory (for high security). +Class SourceControl.Git.Util.PrivateMemoryStore Extends %RegisteredObject +{ + +Property buffer [ Internal, Private ]; + +Property map [ Internal, MultiDimensional, Private ]; + +Property offset [ InitialExpression = 0, Internal, Private ]; + +Property size [ Internal, Private ]; + +Parameter defaultSize = 128; + +Method %OnNew(size As %Integer) As %Status [ Private, ServerOnly = 1 ] +{ + if $DATA(size) && $ISVALIDNUM(size) && (size >= 0) { + set i%size = size + } else { + set i%size = ..#defaultSize + } + + set i%buffer = $zu(106,1,i%size) + return $$$OK +} + +/// Store `key`: `value` to the map +/// throws an exception if key = "" +Method Store(key As %String, value As %String) +{ + if key = "" { + // TODO: throw a better exception + throw ##class(%Exception.General).%New("INVALID_KEY_EXCEPTION","999",, "Invalid key `""""`") + } + set length = $length(value) + // this will clear it if it exists + do ..Clear(key) + set requiredSize = length + i%offset + if (requiredSize > i%size) { + // TODO: there is definitely a better way to find the appropriate next size + // using log_2() but won't do that right now + + if i%size=0 { + set newSize = i%defaultSize + } else { + set newSize = i%size*2 + } + + while requiredSize > newSize { + set newSize = newSize*2 + } + set newBuffer = $zu(106,1,newSize) + + // move values from buffer to newBuffer + do ..compactBuffer(newBuffer, .newMap, .newOffset) + + // clear current buffer and deallocate + do ..deallocateBuffer() + + // set to new values + set i%buffer = newBuffer + set i%size = newSize + set i%offset = newOffset + merge i%map = newMap + } + // add mapping for the key + set i%map(key) = $lb(i%offset,length) + set i%offset = ..insertIntoMemoryStore(value, i%buffer, i%offset) +} + +/// Retreives the value associated with `key` +/// Returns `""` if key does not exist +Method Retrieve(key As %String) As %RawString +{ + return:('..KeyExists(key)) "" + + set $listbuild(offset,length) = i%map(key) + return $view(i%buffer+offset,-3,-length) +} + +/// Deletes the key and its associated value from the map +/// Returns silently if key does not exist +Method Clear(key As %String) +{ + quit:('..KeyExists(key)) + + kill i%map(key) + + do ..compactBuffer(i%buffer, .newMap, .newOffset) + // update the map and offset + kill i%map + merge i%map = newMap + set i%offset = newOffset +} + +Method %OnClose() As %Status [ Private, ServerOnly = 1 ] +{ + do ..deallocateBuffer() +} + +/// Returns true if `key` exists in the map +/// Returns false otherwise +Method KeyExists(key As %String) As %Boolean +{ + if key = "" { + return 0 + } + return $Get(i%map(key)) '= "" +} + +// PRIVATE METHODS ====> + +// Writes to Buffer and returns new offset + +Method insertIntoMemoryStore(value, buffer, offset As %Integer) As %Integer [ Private ] +{ + set length = $length(value) + view buffer+offset:-3:-length:value + return offset + length +} + +Method clearBuffer() [ Private ] +{ + // nulls out buffer + FOR i = 1:1:i%size { + view i%buffer+i:-3:-1:0 + } +} + +// iterate through ..map and move data from ..buffer to buffer + +Method compactBuffer(buffer, Output newMap, Output newOffset) [ Private ] +{ + // pointer to the next place to insert into the buffer + set newOffset = 0 + kill newMap + + do ..getInverseMap(.inverseMap) + // iterate through the offsets in ascending order + set curOffset = "" + for { + set curOffset = $order(inverseMap(curOffset)) + quit:(curOffset = "") + + set key = inverseMap(curOffset) + set value = ..Retrieve(key) + set newMap(key) = $lb(newOffset, $length(value)) + set newOffset = ..insertIntoMemoryStore(value, buffer, newOffset) + } +} + +Method deallocateBuffer() [ Private ] +{ + do ..clearBuffer() + set i%size = 0 + kill i%map + do $zu(106,0,i%buffer) +} + +// using this method to iterate by sorted offset + +// inverseMap is of array type + +Method getInverseMap(Output inverseMap) [ Private ] +{ + kill inverseMap + set iterKey = "" + for { + set iterKey = $order(i%map(iterKey)) + quit:(iterKey = "") + + set list = i%map(iterKey) + set $listbuild(offset,) = list + + set inverseMap(offset) = iterKey + } +} + +// util method to print ..map + +Method printMap() +{ + set iterKey = "" + w ! + for { + set iterKey = $order(i%map(iterKey)) + quit:(iterKey = "") + + w iterKey, ": ", $LISTTOSTRING(i%map(iterKey)), ! + } +} + +} diff --git a/cls/SourceControl/Git/Utils.cls b/cls/SourceControl/Git/Utils.cls index 20c76cc9..09b350ca 100644 --- a/cls/SourceControl/Git/Utils.cls +++ b/cls/SourceControl/Git/Utils.cls @@ -82,6 +82,16 @@ ClassMethod DecomposeProdAllowIDE() As %Boolean [ CodeMode = expression ] $Get(@..#Storage@("settings","decomposeProdAllowIDE"), 0) } +ClassMethod GitRemoteType() As %String +{ + return ##class(SourceControl.Git.Settings).GetRemoteType(..GitRemoteURL()) +} + +ClassMethod GitRemoteURL() As %String [ CodeMode = expression ] +{ +$Get(@..#Storage@("settings","gitRemoteURL"), 0) +} + ClassMethod FavoriteNamespaces() As %String { set favNamespaces = [] @@ -267,6 +277,9 @@ ClassMethod UserAction(InternalName As %String, MenuName As %String, ByRef Targe } elseif (menuItemName = "GitWebUI") { set Action = 2 + externalBrowser set Target = urlPrefix _ "/isc/studio/usertemplates/gitsourcecontrol/webuidriver.csp/"_$namespace_"/"_$zconvert(InternalName,"O","URL")_"?"_urlPostfix + } elseif (menuItemName = "Authenticate") { + set Action = 2 + externalBrowser + set Target = urlPrefix _ "/isc/studio/usertemplates/gitsourcecontrol/oauth2.csp?"_urlPostfix } elseif (menuItemName = "Export") || (menuItemName = "ExportForce") { write !, "==export start==",! set ec = ..ExportAll($case(menuItemName="ExportForce",1:$$$Force,:0)) @@ -1985,6 +1998,18 @@ ClassMethod RunGitCommandWithInput(command As %String, inFile As %String = "", O set gitCommand = $extract(..GitBinPath(),2,*-1) set baseArgs = "/STDOUT="_$$$QUOTE(outLog)_" /STDERR="_$$$QUOTE(errLog)_$case(inFile, "":"", :" /STDIN="_$$$QUOTE(inFile)) + + if ((..GitRemoteType() = "HTTPS") && ((command = "pull") || (command = "push") || (command = "fetch"))) { + set token = ##class(SourceControl.Git.OAuth2).GetToken() + set ^mtempet = token + if (token) { + if ($$$isWINDOWS) { + set env("GIT_ASKPASS") = """cmd /c "_token_""";" + } else { + set env("GIT_ASKPASS") = "sh -c ""echo "_token_"""" + } + } + } try { // Inject instance manager directory as global git config home directory // On Linux, this avoids trying to use /root/.config/git/attributes for global git config @@ -3024,6 +3049,9 @@ ClassMethod GetConfiguredRemote() As %String d ..RunGitCommand("remote",.err,.out,"-v") set line = out.ReadLine() set url = $piece($piece(line,$char(9),2)," ",1) + if (url = "") { + return ..GitRemoteURL() + } return url } @@ -3181,5 +3209,30 @@ ClassMethod GitUnstage(Output output As %Library.DynamicObject) As %Status return $$$OK } +ClassMethod WriteLineToFile(filePath As %String, line As %String) +{ + Set file=##class(%File).%New(filePath) + Do file.Open("WSN") + Do file.WriteLine(line) +} + +ClassMethod Authenticated() As %Boolean +{ + if (##class(SourceControl.Git.Utils).GitRemoteType() '= "SSH") { + try { + // Run a git command that requires access to the remote + // if does not work, then we are unauthenticated + set returncode = ##class(SourceControl.Git.Utils).RunGitCommandWithInput("ls-remote","",.errStream,.outStream,) + do errStream.Rewind() + if (errStream.ReadLine()) '= "" { + return 0 + } + } catch e { + return 0 + } + } + return 1 +} + } diff --git a/cls/SourceControl/Git/WebUIDriver.cls b/cls/SourceControl/Git/WebUIDriver.cls index a7d808cc..e462d1c8 100644 --- a/cls/SourceControl/Git/WebUIDriver.cls +++ b/cls/SourceControl/Git/WebUIDriver.cls @@ -18,6 +18,8 @@ ClassMethod HandleRequest(pagePath As %String, InternalName As %String = "", Out set responseJSON = ..Uncommitted() } elseif $extract(pagePath,6,*) = "settings" { set responseJSON = ..GetSettingsURL(%request) + } elseif $extract(pagePath, 6, *) = "oauth" { + set responseJSON = ..GetOAuthURL(%request) } elseif $extract(pagePath, 6, *) = "get-package-version"{ set responseJSON = ..GetPackageVersion() } elseif $extract(pagePath, 6, *) = "git-version" { @@ -436,6 +438,17 @@ ClassMethod GetSettingsURL(%request As %CSP.Request) As %SystemBase quit {"url": (settingsURL)} } +ClassMethod GetOAuthURL(%request As %CSP.Request) As %SystemBase +{ + set oauthURL = "" + if ('##class(SourceControl.Git.Utils).Authenticated()) { + set oauthURL = "/isc/studio/usertemplates/gitsourcecontrol/oauth2.csp?CSPSHARE=1&Namespace="_$namespace_"&Username="_$username + set oauthURL = ..GetURLPrefix(%request, oauthURL) + } + set ^mtempet = oauthURL + quit {"url": (oauthURL)} +} + ClassMethod GetPackageVersion() As %Library.DynamicObject { set version = ##class(SourceControl.Git.Utils).GetPackageVersion() diff --git a/cls/_zpkg/isc/sc/git/SSLConfig.cls b/cls/_zpkg/isc/sc/git/SSLConfig.cls new file mode 100644 index 00000000..07e81d22 --- /dev/null +++ b/cls/_zpkg/isc/sc/git/SSLConfig.cls @@ -0,0 +1,41 @@ +Class %zpkg.isc.sc.git.SSLConfig +{ + + ClassMethod CreateSSLConfigIfNonExistent(name As %String) { + try { + do ..CheckSSLConfig(name) + } catch e { + return e.AsStatus() + } + return $$$OK + } + + ClassMethod CheckSSLConfig(name As %String) [ Private, NotInheritable ] { + $$$AddAllRoleTemporary + new $namespace + set $namespace = "%SYS" + + do ##class(Security.SSLConfigs).Get(name, .p) + if $data(p) quit + + set p("CipherList")="ALL:!aNULL:!eNULL:!EXP:!SSLv2" + set p("CAFile")="" + set p("CAPath")="" + set p("CRLFile")="" + set p("CertificateFile")="" + set p("CipherList")="ALL:!aNULL:!eNULL:!EXP:!SSLv2" + set p("Description")="" + set p("Enabled")=1 + set p("PrivateKeyFile")="" + set p("PrivateKeyPassword")="" + set p("PrivateKeyType")=2 + set p("Protocols")=24 + set p("SNIName")="" + set p("Type")=0 + set p("VerifyDepth")=9 + set p("VerifyPeer")=0 + + do ##class(Security.SSLConfigs).Create(name, .p) + } + +} \ No newline at end of file diff --git a/csp/oauth2.csp b/csp/oauth2.csp new file mode 100644 index 00000000..7503c9f9 --- /dev/null +++ b/csp/oauth2.csp @@ -0,0 +1,242 @@ + + + + + + +HTTPS OAuth Configuration + + + + + + + set authenticated = 0 + if $Data(%request.Data("state",1),state)#2 { + // Redirected here from github + // switch to the namespace that the extension is installed to + set namespace = $Piece(state,"_",1) + new $Namespace + set $Namespace = namespace + + set config = ##class(SourceControl.Git.OAuth2.Config).GetConfig($username) + if (config.state '= state){ + w "Invalid State" + quit 1 + } + + if '$Data(%request.Data("code",1),code)#2 { + w "Bad request: Invalid parameters" + quit 1 + } + + set verifier = config.verifier + + set result = config.Exchange(code, verifier, .sc) + if sc '= $$$OK { + do $SYSTEM.Status.DisplayError(sc) + w "
" + w "Unable to retreive access token" + } else { + do ##class(SourceControl.Git.Util.CredentialManager).SetToken($username,result, .err, .code) + if (code '= 1) || (err '= "") { + w "Unable to save credential" + } else { + set authenticated = 1 + } + } + + + // https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GNET_http + + } + set namespace = $NAMESPACE + set username = $USERNAME + set config = ##class(SourceControl.Git.OAuth2.Config).GetConfig(username) + + if (config = "") { + set authURL = "" + set tokenURL = "" + set remote = ##class(SourceControl.Git.Utils).GetConfiguredRemote() + set urls = ##class(SourceControl.Git.OAuth2).GetURLsFromRemote(remote,.authURL,.tokenURL) + } + set authCodeURL = "" + set ready = "" + /// After submit + if (%request.Method="POST") && $Data(%request.Data("oauthsettings",1)) { + set ready = 1 + set clientID = $Get(%request.Data("clientID",1)) + set clientSecret = $Get(%request.Data("clientSecret",1)) + set authURL = $Get(%request.Data("authURL",1)) + set tokenURL = $Get(%request.Data("tokenURL",1)) + set redirect = $piece($Get(%request.Data("redirectURL",1)),"?",1) + + if config = "" { + set config = ##class(SourceControl.Git.OAuth2.Config).%New(username, clientID, clientSecret,authURL,tokenURL,redirect) + } else { + set config.ClientID = clientID + set config.ClientSecret = clientSecret + set config.Endpoint.AuthURL = authURL + set config.Endpoint.TokenURL = tokenURL + set config.RedirectURL = redirect + } + + do config.%Save() + + set authCodeURL = ##class(SourceControl.Git.OAuth2).AuthCodeURL(config,namespace,.state,.verifier) + set config.state = state + set config.verifier = verifier + do config.%Save() + } +
+

+ +
+ × + Success! You have been authenticated. +
+
+ +
+
+

+ To connect your GitHub or GitLab repository, you need to generate a Client ID and Client Secret. + Follow these steps: +

+ +

Once generated, enter your Client ID and Client Secret below:

+

The "Authorization callback URL" should be: text

+
+ +
+ +
+ + set clientID = $select(config:config.ClientID, 1: "") + + +
+
+ +
+ +
+ + set clientSecret = $select(config:config.ClientSecret, 1: "") + + +
+
+ +
+ +
+ + set authURL = $select(config:config.Endpoint.AuthURL,authURL'="":authURL, 1: "") + + +
+
+ +
+ +
+ + set tokenURL = $select(config:config.Endpoint.TokenURL, tokenURL'="":tokenURL, 1: "") + + +
+
+ +
+
+ + +
+
+ + +
+ + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/docs/https.md b/docs/https.md new file mode 100644 index 00000000..947ebb07 --- /dev/null +++ b/docs/https.md @@ -0,0 +1,18 @@ +## Setting up HTTPS + +We highly recommend that you use SSH to connect to your repositories. If this is not possible, then HTTPS is another option. + +First, add your remote repo in the settings page, or during the Configure step. + +After this, you have to authenticate using OAuth tokens. To do this, press "Authenticate" in the bottom left of the Embedded Git UI, or from the Source Control Menu. + +### Authentication + +If you have not already done so, create a new OAuth app in github or gitlab. The "Authorization callback URL" should be <your url>/isc/studio/usertemplates/gitsourcecontrol/oauth2.csp. + +Remember to save the ClientID and ClientSecret. Once this is finished, you can enter your information into the authentication page. +![Screenshot of authentication page](/images/auth.png) + +Once all of the information is correct, you can press Save. This will redirect you to either gitlab or github in order to authorize your application. After this is done, you will be redirected back to the authentication page, and you should be good to go! + + diff --git a/docs/images/auth.png b/docs/images/auth.png new file mode 100644 index 00000000..e6f7e7ac Binary files /dev/null and b/docs/images/auth.png differ diff --git a/git-webui/release/share/git-webui/webui/css/git-webui.css b/git-webui/release/share/git-webui/webui/css/git-webui.css index 02e47be8..12e9c644 100644 --- a/git-webui/release/share/git-webui/webui/css/git-webui.css +++ b/git-webui/release/share/git-webui/webui/css/git-webui.css @@ -241,6 +241,22 @@ body { width: 16.4em; background-color: #333333; } +#sidebar #sidebar-content #sidebar-oauth a { + color: white; +} +#sidebar #sidebar-content #sidebar-oauth h4 { + padding: 0px; + margin-bottom: 10px; +} +#sidebar #sidebar-content #sidebar-oauth h4:before { + content: url(../img/oauth.svg); +} +#sidebar #sidebar-content #sidebar-oauth { + position: absolute; + bottom: 120px; + width: 16.4em; + background-color: #333333; +} #sidebar #sidebar-content #sidebar-context h4 { padding: 0px; margin-bottom: 10px; diff --git a/git-webui/release/share/git-webui/webui/img/oauth.svg b/git-webui/release/share/git-webui/webui/img/oauth.svg new file mode 100644 index 00000000..b2c4fa95 --- /dev/null +++ b/git-webui/release/share/git-webui/webui/img/oauth.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/git-webui/release/share/git-webui/webui/js/git-webui.js b/git-webui/release/share/git-webui/webui/js/git-webui.js index a8ba50aa..240950b2 100644 --- a/git-webui/release/share/git-webui/webui/js/git-webui.js +++ b/git-webui/release/share/git-webui/webui/js/git-webui.js @@ -938,6 +938,10 @@ webui.SideBarView = function(mainView, noEventHandlers) { window.location.href = webui.settingsURL; } + self.goToOAuth = function() { + window.location.href = webui.oauthURL; + } + self.goToHomePage = function() { window.location.href = webui.homeURL; } @@ -993,6 +997,12 @@ webui.SideBarView = function(mainView, noEventHandlers) { self.mainView = mainView; self.currentContext = self.getCurrentContext(); + + var oauthHTML = ''; + if (webui.oauthURL != "") { + oauthHTML = '' + } + self.element = $( '