├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── ghcr_publish.yml │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── screenshots ├── screenshot01.png └── screenshot02.png └── src ├── .config └── dotnet-tools.json ├── .dockerignore ├── .editorconfig ├── App.razor ├── Components ├── AutoCompleteMoreItemsFound.razor ├── AutoCompleteNoItemsFound.razor ├── LapsInformationDetail.razor └── LapsInformationDetail.razor.cs ├── Dialogs └── Confirmation.razor ├── Dockerfile ├── Enums └── LAPSVersion.cs ├── Interfaces ├── ICryptService.cs ├── ILDAPService.cs └── ISessionManagerService.cs ├── LAPS-WebUI.csproj ├── LAPS-WebUI.sln ├── Models ├── ADComputer.cs ├── Domain.cs ├── LDAPOptions.cs ├── LapsInformation.cs ├── LapsOptions.cs ├── UserLoginRequest.cs └── msLAPSPayload.cs ├── Pages ├── Error.cshtml ├── Error.cshtml.cs ├── Index.razor ├── Index.razor.cs ├── LAPS.razor ├── LAPS.razor.cs ├── Login.razor ├── Login.razor.cs ├── Logout.razor ├── Logout.razor.cs ├── _Host.cshtml └── _Layout.cshtml ├── Program.cs ├── Properties └── launchSettings.json ├── Services ├── CryptService.cs ├── LDAPService.cs └── SessionManagerService.cs ├── Shared ├── MainLayout.razor ├── MainLayout.razor.cs └── MainLayout.razor.css ├── _Imports.razor ├── appsettings.json.example ├── scripts └── DecryptEncryptedLAPSPassword.py └── wwwroot ├── favicon.ico ├── icon-128.png ├── icon-192.png ├── icon-512.png ├── icon-72.png ├── icon-96.png ├── manifest.json ├── service-worker.js └── service-worker.published.js /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: seji64 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/ghcr_publish.yml: -------------------------------------------------------------------------------- 1 | name: GHCR Publish 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | workflow_dispatch: 10 | schedule: 11 | - cron: '0 17 */10 * *' 12 | push: 13 | branches: [ main ] 14 | paths-ignore: 15 | - '**/README.md' 16 | # Publish semver tags as releases. 17 | tags: [ 'v*.*.*' ] 18 | pull_request: 19 | branches: [ main ] 20 | paths-ignore: 21 | - '**/README.md' 22 | 23 | env: 24 | # Use docker.io for Docker Hub if empty 25 | REGISTRY: ghcr.io 26 | # github.repository as / 27 | IMAGE_NAME: ${{ github.repository }} 28 | 29 | 30 | jobs: 31 | build: 32 | 33 | runs-on: ubuntu-latest 34 | permissions: 35 | contents: read 36 | packages: write 37 | # This is used to complete the identity challenge 38 | # with sigstore/fulcio when running outside of PRs. 39 | id-token: write 40 | 41 | steps: 42 | 43 | - name: Checkout repository 44 | uses: actions/checkout@v3 45 | 46 | - name: Set up QEMU 47 | uses: docker/setup-qemu-action@v2 48 | 49 | - name: Set up Docker Buildx 50 | uses: docker/setup-buildx-action@v2 51 | 52 | - name: Log into registry ${{ env.REGISTRY }} 53 | if: github.event_name != 'pull_request' 54 | uses: docker/login-action@v2 55 | with: 56 | registry: ${{ env.REGISTRY }} 57 | username: ${{ github.actor }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | # Extract metadata (tags, labels) for Docker 61 | # https://github.com/docker/metadata-action 62 | - name: Extract Docker metadata 63 | id: meta 64 | uses: docker/metadata-action@v4 65 | with: 66 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 67 | tags: | 68 | type=schedule 69 | type=ref,event=branch 70 | type=ref,event=pr 71 | type=semver,pattern={{version}} 72 | type=semver,pattern={{major}}.{{minor}} 73 | type=semver,pattern={{major}} 74 | latest 75 | 76 | # Build and push Docker image with Buildx (don't push on PR) 77 | # https://github.com/docker/build-push-action 78 | - name: Build and push Docker image 79 | id: build-and-push 80 | uses: docker/build-push-action@v3 81 | with: 82 | context: ./src 83 | push: ${{ github.event_name != 'pull_request' }} 84 | tags: ${{ steps.meta.outputs.tags }} 85 | labels: ${{ steps.meta.outputs.labels }} 86 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | 2 | # This is a basic workflow to help you get started with Action 3 | name: CI 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | release: 15 | name: Release 16 | strategy: 17 | matrix: 18 | kind: ['linux', 'windows', 'macOS'] 19 | include: 20 | - kind: linux 21 | os: ubuntu-latest 22 | target: linux-x64 23 | - kind: windows 24 | os: windows-latest 25 | target: win-x64 26 | - kind: macOS 27 | os: macos-latest 28 | target: osx-x64 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | - run: | 36 | git tag --list 37 | git describe --tags --abbrev=0 38 | 39 | - name: Setup dotnet 40 | uses: actions/setup-dotnet@v4 41 | with: 42 | dotnet-version: '9.0.x' 43 | 44 | - name: Build 45 | shell: bash 46 | run: | 47 | tag=$(git describe --tags --abbrev=0) 48 | release_name="LAPS-WebUI-$tag-${{ matrix.target }}" 49 | # Build everything 50 | dotnet publish src/LAPS-WebUI.csproj --self-contained --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" 51 | # Pack files 52 | if [ "${{ matrix.target }}" == "win-x64" ]; then 53 | # Pack to zip for Windows 54 | 7z a -tzip "${release_name}.zip" "./${release_name}/*" 55 | else 56 | tar czvf "${release_name}.tar.gz" "$release_name" 57 | fi 58 | # Delete output directory 59 | rm -r "$release_name" 60 | - name: Publish 61 | uses: softprops/action-gh-release@v2 62 | with: 63 | files: "LAPS-WebUI*" 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | src/appsettings.Development.json 7 | 8 | # User-specific files 9 | *.rsuser 10 | *.suo 11 | *.user 12 | *.userosscache 13 | *.sln.docstates 14 | 15 | # User-specific files (MonoDevelop/Xamarin Studio) 16 | *.userprefs 17 | 18 | # Build results 19 | [Dd]ebug/ 20 | [Dd]ebugPublic/ 21 | [Rr]elease/ 22 | [Rr]eleases/ 23 | x64/ 24 | x86/ 25 | [Aa][Rr][Mm]/ 26 | [Aa][Rr][Mm]64/ 27 | bld/ 28 | [Bb]in/ 29 | [Oo]bj/ 30 | [Ll]og/ 31 | 32 | # Visual Studio 2015/2017 cache/options directory 33 | .vs/ 34 | # Uncomment if you have tasks that create the project's static files in wwwroot 35 | #wwwroot/ 36 | 37 | # Visual Studio 2017 auto generated files 38 | Generated\ Files/ 39 | 40 | # MSTest test Results 41 | [Tt]est[Rr]esult*/ 42 | [Bb]uild[Ll]og.* 43 | 44 | # NUNIT 45 | *.VisualState.xml 46 | TestResult.xml 47 | 48 | # Build Results of an ATL Project 49 | [Dd]ebugPS/ 50 | [Rr]eleasePS/ 51 | dlldata.c 52 | 53 | # Benchmark Results 54 | BenchmarkDotNet.Artifacts/ 55 | 56 | # .NET Core 57 | project.lock.json 58 | project.fragment.lock.json 59 | artifacts/ 60 | 61 | # StyleCop 62 | StyleCopReport.xml 63 | 64 | # Files built by Visual Studio 65 | *_i.c 66 | *_p.c 67 | *_h.h 68 | *.ilk 69 | *.meta 70 | *.obj 71 | *.iobj 72 | *.pch 73 | *.pdb 74 | *.ipdb 75 | *.pgc 76 | *.pgd 77 | *.rsp 78 | *.sbr 79 | *.tlb 80 | *.tli 81 | *.tlh 82 | *.tmp 83 | *.tmp_proj 84 | *_wpftmp.csproj 85 | *.log 86 | *.vspscc 87 | *.vssscc 88 | .builds 89 | *.pidb 90 | *.svclog 91 | *.scc 92 | 93 | # Chutzpah Test files 94 | _Chutzpah* 95 | 96 | # Visual C++ cache files 97 | ipch/ 98 | *.aps 99 | *.ncb 100 | *.opendb 101 | *.opensdf 102 | *.sdf 103 | *.cachefile 104 | *.VC.db 105 | *.VC.VC.opendb 106 | 107 | # Visual Studio profiler 108 | *.psess 109 | *.vsp 110 | *.vspx 111 | *.sap 112 | 113 | # Visual Studio Trace Files 114 | *.e2e 115 | 116 | # TFS 2012 Local Workspace 117 | $tf/ 118 | 119 | # Guidance Automation Toolkit 120 | *.gpState 121 | 122 | # ReSharper is a .NET coding add-in 123 | _ReSharper*/ 124 | *.[Rr]e[Ss]harper 125 | *.DotSettings.user 126 | 127 | # JustCode is a .NET coding add-in 128 | .JustCode 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # The packages folder can be ignored because of Package Restore 188 | **/[Pp]ackages/* 189 | # except build/, which is used as an MSBuild target. 190 | !**/[Pp]ackages/build/ 191 | # Uncomment if necessary however generally it will be regenerated when needed 192 | #!**/[Pp]ackages/repositories.config 193 | # NuGet v3's project.json files produces more ignorable files 194 | *.nuget.props 195 | *.nuget.targets 196 | 197 | # Microsoft Azure Build Output 198 | csx/ 199 | *.build.csdef 200 | 201 | # Microsoft Azure Emulator 202 | ecf/ 203 | rcf/ 204 | 205 | # Windows Store app package directories and files 206 | AppPackages/ 207 | BundleArtifacts/ 208 | Package.StoreAssociation.xml 209 | _pkginfo.txt 210 | *.appx 211 | 212 | # Visual Studio cache files 213 | # files ending in .cache can be ignored 214 | *.[Cc]ache 215 | # but keep track of directories ending in .cache 216 | !?*.[Cc]ache/ 217 | 218 | # Others 219 | ClientBin/ 220 | ~$* 221 | *~ 222 | *.dbmdl 223 | *.dbproj.schemaview 224 | *.jfm 225 | *.pfx 226 | *.publishsettings 227 | orleans.codegen.cs 228 | 229 | # Including strong name files can present a security risk 230 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 231 | #*.snk 232 | 233 | # Since there are multiple workflows, uncomment next line to ignore bower_components 234 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 235 | #bower_components/ 236 | 237 | # RIA/Silverlight projects 238 | Generated_Code/ 239 | 240 | # Backup & report files from converting an old project file 241 | # to a newer Visual Studio version. Backup files are not needed, 242 | # because we have git ;-) 243 | _UpgradeReport_Files/ 244 | Backup*/ 245 | UpgradeLog*.XML 246 | UpgradeLog*.htm 247 | ServiceFabricBackup/ 248 | *.rptproj.bak 249 | 250 | # SQL Server files 251 | *.mdf 252 | *.ldf 253 | *.ndf 254 | 255 | # Business Intelligence projects 256 | *.rdl.data 257 | *.bim.layout 258 | *.bim_*.settings 259 | *.rptproj.rsuser 260 | *- Backup*.rdl 261 | 262 | # Microsoft Fakes 263 | FakesAssemblies/ 264 | 265 | # GhostDoc plugin setting file 266 | *.GhostDoc.xml 267 | 268 | # Node.js Tools for Visual Studio 269 | .ntvs_analysis.dat 270 | node_modules/ 271 | 272 | # Visual Studio 6 build log 273 | *.plg 274 | 275 | # Visual Studio 6 workspace options file 276 | *.opt 277 | 278 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 279 | *.vbw 280 | 281 | # Visual Studio LightSwitch build output 282 | **/*.HTMLClient/GeneratedArtifacts 283 | **/*.DesktopClient/GeneratedArtifacts 284 | **/*.DesktopClient/ModelManifest.xml 285 | **/*.Server/GeneratedArtifacts 286 | **/*.Server/ModelManifest.xml 287 | _Pvt_Extensions 288 | 289 | # Paket dependency manager 290 | .paket/paket.exe 291 | paket-files/ 292 | 293 | # FAKE - F# Make 294 | .fake/ 295 | 296 | # JetBrains Rider 297 | .idea/ 298 | *.sln.iml 299 | 300 | # CodeRush personal settings 301 | .cr/personal 302 | 303 | # Python Tools for Visual Studio (PTVS) 304 | __pycache__/ 305 | *.pyc 306 | 307 | # Cake - Uncomment if you are using it 308 | # tools/** 309 | # !tools/packages.config 310 | 311 | # Tabs Studio 312 | *.tss 313 | 314 | # Telerik's JustMock configuration file 315 | *.jmconfig 316 | 317 | # BizTalk build output 318 | *.btp.cs 319 | *.btm.cs 320 | *.odx.cs 321 | *.xsd.cs 322 | 323 | # OpenCover UI analysis results 324 | OpenCover/ 325 | 326 | # Azure Stream Analytics local run output 327 | ASALocalRun/ 328 | 329 | # MSBuild Binary and Structured Log 330 | *.binlog 331 | 332 | # NVidia Nsight GPU debugger configuration file 333 | *.nvuser 334 | 335 | # MFractors (Xamarin productivity tool) working folder 336 | .mfractor/ 337 | 338 | # Local History for Visual Studio 339 | .localhistory/ 340 | 341 | # BeatPulse healthcheck temp database 342 | healthchecksdb -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Seji 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LAPS-WebUI 2 | 3 | A simple web interface for Microsoft LAPS (Local Administrator Password Solution). 4 | 5 | --- 6 | 7 | ## 📚 About 8 | 9 | This is a modern frontend for Microsoft LAPS, supporting: 10 | - LAPS v1 and v2 11 | - Multiple Active Directory domains 12 | - Authentication directly via Active Directory 13 | - Bare-metal and Docker deployment 14 | 15 | No additional user management is needed — access is fully controlled by Active Directory permissions. 16 | 17 | --- 18 | 19 | ## ⚠️ Version 1.6.0 Notice 20 | 21 | > Starting with version 1.6.0, multi-domain support was added. 22 | > As a result, the configuration format has changed. 23 | > Review the updated `appsettings.json.example` for details and adjust your setup accordingly. 24 | 25 | --- 26 | 27 | ## 🛠 Requirements 28 | 29 | - Active Directory with Microsoft LAPS installed 30 | - .NET 9 runtime or a Docker host 31 | - Python 3 with `dpapi-ng` installed: 32 | ```bash 33 | pip install dpapi-ng[kerberos] 34 | 35 | ### Bare Metal: 36 | 37 | - Download the latest Release for your Platform 38 | - Unzip Archive 39 | - Rename `appsettings.json.example` to `appsettings.json` and edit as needed or set the settings via Environment Variables 40 | - Ensure Python3 and dpapi-ng (`pip install dpapi-ng[kerberos]`) is installed 41 | - Run *LAPS-WebUI* 42 | 43 | ### Notes for LAPS v2 44 | - Since Version 1.5.0 LAPS v2 is supported 45 | - By default, LAPS v2 passwords are encrypted. If the LAPS v2 passwords are stored unencrypted, then you have to set 46 | `EncryptionDisabled` to `true` in the settings 47 | - When LAPS v2 Passwords are encrypted a direct connection to the domain controllers with `Kerberos` and `DCE-RPC` is needed in order to decrypt those passwords. For LAPS v1 and unecrypted LAPS v2 passwords only `LDAP` is needed 48 | 49 | ## Setup (docker): 50 | 51 | Running LAPS-WebUI in docker is quite easy: 52 | ``` 53 | docker run -d \ 54 | --name=lapswebui \ 55 | -e Domains__0__Name=example.com \ 56 | -e Domains__0__Ldap__Server=ldap.example.com \ 57 | -e Domains__0__Ldap__Port=389 \ 58 | -e Domains__0__Ldap__UseSSL=false \ 59 | -e Domains__0__Ldap__TrustAllCertificates=true \ 60 | -e Domains__0__Ldap__SearchBase='DC=example,DC=com' \ 61 | -p 8080:8080 \ 62 | --restart unless-stopped \ 63 | ghcr.io/seji64/laps-webui:1.6 64 | ``` 65 | 66 | ## ⚙️ Advanced Configuration 67 | - Listen address and port: [Learn more](https://andrewlock.net/exploring-the-dotnet-8-preview-updates-to-docker-images-in-dotnet-8/) 68 | - Behind a reverse proxy: WebSocket support must be enabled! 69 | 70 | 71 | ## 🧑‍💻 Usage 72 | - Access the app at: http://127.0.0.1:8080 73 | - Authenticate with your Active Directory user credentials 74 | - Search for a computer by its name 75 | - Click on the result to display the LAPS-managed password 76 | 77 | ## ❓ FAQ 78 | ### Why is there no user management? 79 | Authentication and authorization are fully handled by Active Directory. 80 | 81 | ### What LAPS versions are supported? 82 | Both Microsoft LAPS v1 (legacy) and LAPS v2 (modern) are supported. 83 | 84 | ## Screenshots: 85 | 86 | ![Screenshot](https://raw.githubusercontent.com/Seji64/LAPS-WebUI/master/screenshots/screenshot01.png) 87 | 88 | ![Screenshot](https://raw.githubusercontent.com/Seji64/LAPS-WebUI/master/screenshots/screenshot02.png) 89 | -------------------------------------------------------------------------------- /screenshots/screenshot01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/screenshots/screenshot01.png -------------------------------------------------------------------------------- /screenshots/screenshot02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/screenshots/screenshot02.png -------------------------------------------------------------------------------- /src/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "7.0.11", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /src/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # S112: General exceptions should never be thrown 4 | dotnet_diagnostic.S112.severity = none 5 | -------------------------------------------------------------------------------- /src/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | Not found 8 | 9 |

Sorry, there's nothing at this address.

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /src/Components/AutoCompleteMoreItemsFound.razor: -------------------------------------------------------------------------------- 1 |  2 | Too much Items found - please refine your search 3 | -------------------------------------------------------------------------------- /src/Components/AutoCompleteNoItemsFound.razor: -------------------------------------------------------------------------------- 1 |  2 | No items found 3 | -------------------------------------------------------------------------------- /src/Components/LapsInformationDetail.razor: -------------------------------------------------------------------------------- 1 | @inherits MudComponentBase 2 | @inject ISnackbar Snackbar 3 | @inject ClipboardService Clipboard 4 | @using CurrieTechnologies.Razor.Clipboard 5 | 6 | @if (LapsInfo != null) 7 | { 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | @if (LapsInfo.Account != null) 22 | { 23 | 24 | } 25 | @if (LapsInfo.PasswordSetDate != null) 26 | { 27 | 28 | } 29 | 30 | 31 | } -------------------------------------------------------------------------------- /src/Components/LapsInformationDetail.razor.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Models; 2 | using Microsoft.AspNetCore.Components; 3 | using MudBlazor; 4 | 5 | namespace LAPS_WebUI.Components 6 | { 7 | public partial class LapsInformationDetail : MudComponentBase 8 | { 9 | [Parameter] public LapsInformation? LapsInfo { get; set; } 10 | [Parameter] public MudTabs? MudTab { get; set; } 11 | private bool IsCopyToClipboardSupported { get; set; } 12 | 13 | protected override async Task OnAfterRenderAsync(bool firstRender) 14 | { 15 | if (firstRender) 16 | { 17 | IsCopyToClipboardSupported = await Clipboard.IsSupportedAsync(); 18 | } 19 | } 20 | 21 | private bool IsCopyButtonDisabled() 22 | { 23 | return !IsCopyToClipboardSupported || LapsInfo is null || string.IsNullOrEmpty(LapsInfo.Password); 24 | } 25 | private async Task CopyLapsPasswordToClipboardAsync() 26 | { 27 | if (LapsInfo != null && !string.IsNullOrEmpty(LapsInfo.Password)) 28 | { 29 | await Clipboard.WriteTextAsync(LapsInfo.Password); 30 | Snackbar.Add("Copied password to clipboard!", Severity.Success); 31 | } 32 | else 33 | { 34 | Snackbar.Add("Failed to copy password to clipboard!", Severity.Error); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Dialogs/Confirmation.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | @ContentText 6 | 7 | @if (ContentListItems != null && ContentListItems.Any()) 8 | { 9 | foreach (string? item in ContentListItems) 10 | { 11 | 12 | 13 | 14 | } 15 | } 16 | 17 | 18 | 19 | @if (ShowCancelButton) 20 | { 21 | @CancelButtonText 22 | } 23 | @ConfirmButtonText 24 | 25 | 26 | @code { 27 | #nullable disable 28 | [CascadingParameter] IMudDialogInstance MudDialog { get; set; } 29 | [Parameter] public string ContentText { get; set; } 30 | [Parameter] public List ContentListItems { get; set; } 31 | [Parameter] public string ConfirmButtonText { get; set; } 32 | [Parameter] public string CancelButtonText { get; set; } 33 | [Parameter] public Color ConfirmButtonColor { get; set; } 34 | [Parameter] public bool ShowCancelButton { get; set; } = true; 35 | [Parameter] public Severity Severity { get; set; } 36 | [Parameter] public string Icon { get; set; } = Icons.Material.Outlined.QuestionMark; 37 | 38 | void Submit() => MudDialog.Close(DialogResult.Ok(true)); 39 | void Cancel() => MudDialog.Cancel(); 40 | } 41 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base 4 | WORKDIR /app 5 | EXPOSE 8080 6 | EXPOSE 8443 7 | 8 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 9 | WORKDIR /src 10 | COPY ["LAPS-WebUI.csproj", "."] 11 | RUN dotnet restore "./LAPS-WebUI.csproj" 12 | COPY . . 13 | WORKDIR "/src/." 14 | RUN dotnet build "LAPS-WebUI.csproj" -c Release -o /app/build 15 | 16 | FROM build AS publish 17 | RUN dotnet publish "LAPS-WebUI.csproj" -c Release -o /app/publish 18 | 19 | FROM base AS final 20 | WORKDIR /app 21 | RUN apt update && \ 22 | apt upgrade -y && \ 23 | apt install --no-install-recommends -y ca-certificates libldap-common gcc python3 python3-dev python3-pip libkrb5-dev curl && \ 24 | apt clean && \ 25 | rm -rf /var/lib/apt/lists/* && \ 26 | ln -s /usr/lib/x86_64-linux-gnu/libldap-2.5.so.0 /usr/lib/libldap.so.2 && \ 27 | ln -s /usr/lib/x86_64-linux-gnu/liblber-2.5.so.0 /usr/lib/liblber.so.2 && \ 28 | pip3 install dpapi-ng[kerberos] --break-system-packages 29 | COPY --from=publish /app/publish . 30 | HEALTHCHECK CMD curl --fail http://localhost:8080/healthz || exit 31 | USER app 32 | ENTRYPOINT ["dotnet", "LAPS-WebUI.dll"] -------------------------------------------------------------------------------- /src/Enums/LAPSVersion.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Enums 2 | { 3 | public enum LAPSVersion 4 | { 5 | All = 0, 6 | v1 = 1, 7 | v2 = 2 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Interfaces/ICryptService.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Interfaces 2 | { 3 | public interface ICryptService 4 | { 5 | public string EncryptString(string text); 6 | public string DecryptString(string cipherText); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Interfaces/ILDAPService.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Enums; 2 | using LAPS_WebUI.Models; 3 | using LdapForNet; 4 | 5 | namespace LAPS_WebUI.Interfaces 6 | { 7 | public interface ILdapService 8 | { 9 | Task CreateBindAsync(string domainName, string username, string password); 10 | Task TestCredentialsAsync(string domainName, string username, string password); 11 | Task TestCredentialsAsync(string domainName, LdapCredential ldapCredential); 12 | Task> GetDomainsAsync(); 13 | public Task GetAdComputerAsync(string domainName, LdapCredential ldapCredential, string distinguishedName); 14 | public Task> SearchAdComputersAsync(string domainName, LdapCredential ldapCredential, string query); 15 | public Task ClearLapsPassword(string domainName, LdapCredential ldapCredential, string distinguishedName, LAPSVersion version); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Interfaces/ISessionManagerService.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Interfaces 2 | { 3 | public interface ISessionManagerService 4 | { 5 | public Task IsUserLoggedInAsync(); 6 | public Task LoginAsync(string domainName, string username, string password); 7 | public Task LogoutAsync(); 8 | public Task GetLdapCredentialsAsync(); 9 | public Task> GetDomainsAsync(); 10 | public Task GetDomainAsync(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/LAPS-WebUI.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | LAPS_WebUI 8 | af0a7ba1-7b27-4fd8-974d-60ebcbc2686f 9 | Linux 10 | . 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | PreserveNewest 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\roboto-fontface-material.css" /> 50 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\Roboto-Light.woff" /> 51 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\Roboto-Light.woff2" /> 52 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\Roboto-Medium.woff" /> 53 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\Roboto-Medium.woff2" /> 54 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\Roboto-Regular.woff" /> 55 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\fonts\Roboto-Regular.woff2" /> 56 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\LICENSE" /> 57 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\package.json" /> 58 | <_ContentIncludedByDefault Remove="wwwroot\roboto-fontface-material\README.md" /> 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/LAPS-WebUI.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32519.379 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LAPS-WebUI", "LAPS-WebUI.csproj", "{7B7F4DF1-306F-4A4A-B074-7CF2D731F39A}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A62D500C-9708-45CC-A92F-E9D61AB47264}" 9 | ProjectSection(SolutionItems) = preProject 10 | .editorconfig = .editorconfig 11 | EndProjectSection 12 | EndProject 13 | Global 14 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 15 | Debug|Any CPU = Debug|Any CPU 16 | Release|Any CPU = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 19 | {7B7F4DF1-306F-4A4A-B074-7CF2D731F39A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {7B7F4DF1-306F-4A4A-B074-7CF2D731F39A}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {7B7F4DF1-306F-4A4A-B074-7CF2D731F39A}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {7B7F4DF1-306F-4A4A-B074-7CF2D731F39A}.Release|Any CPU.Build.0 = Release|Any CPU 23 | EndGlobalSection 24 | GlobalSection(SolutionProperties) = preSolution 25 | HideSolutionNode = FALSE 26 | EndGlobalSection 27 | GlobalSection(ExtensibilityGlobals) = postSolution 28 | SolutionGuid = {D0D67EB9-1BCE-43B8-BFB8-6AA86AFB1DA1} 29 | EndGlobalSection 30 | EndGlobal 31 | -------------------------------------------------------------------------------- /src/Models/ADComputer.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Models 2 | { 3 | public class AdComputer(string distinguishedName, string name) 4 | { 5 | public string Name { get; set; } = name; 6 | public string DistinguishedName { get; set; } = distinguishedName; 7 | public List? LapsInformations { get; set; } 8 | public bool FailedToRetrieveLapsDetails { get; set; } 9 | public bool Loading => LapsInformations is null; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Models/Domain.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Models 2 | { 3 | public class Domain 4 | { 5 | public required string Name { get; set; } 6 | public required LdapOptions Ldap { get; set; } 7 | public LapsOptions Laps { get; set; } = new(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Models/LDAPOptions.cs: -------------------------------------------------------------------------------- 1 | using LdapForNet.Native; 2 | 3 | namespace LAPS_WebUI.Models 4 | { 5 | public class LdapOptions 6 | { 7 | public string? Server { get; set; } 8 | public int Port { get; set; } 9 | public bool UseSsl { get; set; } 10 | public bool TrustAllCertificates { get; set; } 11 | public string? SearchBase { get; set; } 12 | public string AuthMechanism { get; set; } = "SIMPLE"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Models/LapsInformation.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Enums; 2 | 3 | namespace LAPS_WebUI.Models 4 | { 5 | public class LapsInformation 6 | { 7 | public required string ComputerName { get; set; } 8 | public string? Password { get; set; } 9 | public string? Account { get; set; } 10 | public DateTime? PasswordExpireDate { get; set; } 11 | public DateTime? PasswordSetDate { get; set; } 12 | public LAPSVersion? Version { get; set; } 13 | public bool IsCurrent { get; set; } 14 | public bool WasEncrypted { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Models/LapsOptions.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Enums; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace LAPS_WebUI.Models 5 | { 6 | public class LapsOptions 7 | { 8 | [JsonConverter(typeof(JsonStringEnumMemberConverter))] 9 | public LAPSVersion ForceVersion { get; set; } = LAPSVersion.All; 10 | public bool EncryptionDisabled { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Models/UserLoginRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace LAPS_WebUI.Models 4 | { 5 | public class UserLoginRequest 6 | { 7 | [Required] 8 | public string? Username { get; set; } 9 | [Required] 10 | public string? Password { get; set; } 11 | 12 | [Required] 13 | public string? DomainName { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Models/msLAPSPayload.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace LAPS_WebUI.Models 4 | { 5 | public class MsLapsPayload 6 | { 7 | [JsonPropertyName("n")] 8 | public string? ManagedAccountName { get; set; } 9 | 10 | [JsonPropertyName("t")] 11 | public string? PasswordUpdateTime { get; set; } 12 | 13 | [JsonPropertyName("p")] 14 | public string? Password { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Pages/Error.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model LAPS_WebUI.Pages.ErrorModel 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Error 11 | 12 | 13 | 14 |
15 |
16 |

Error.

17 |

An error occurred while processing your request.

18 | 19 | @if (Model.ShowRequestId) 20 | { 21 |

22 | Request ID: @Model.RequestId 23 |

24 | } 25 | 26 |

Development Mode

27 |

28 | Swapping to the Development environment displays detailed information about the error that occurred. 29 |

30 |

31 | The Development environment shouldn't be enabled for deployed applications. 32 | It can result in displaying sensitive information from exceptions to end users. 33 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 34 | and restarting the app. 35 |

36 |
37 |
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/Pages/Error.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | using System.Diagnostics; 4 | 5 | namespace LAPS_WebUI.Pages 6 | { 7 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 8 | [IgnoreAntiforgeryToken] 9 | public class ErrorModel : PageModel 10 | { 11 | public string? RequestId { get; set; } 12 | 13 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 14 | 15 | private readonly ILogger _logger; 16 | 17 | public ErrorModel(ILogger logger) 18 | { 19 | _logger = logger; 20 | } 21 | 22 | public void OnGet() 23 | { 24 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @inject ISessionManagerService SessionManager 3 | @inject NavigationManager NavigationManager -------------------------------------------------------------------------------- /src/Pages/Index.razor.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Pages 2 | { 3 | public partial class Index 4 | { 5 | protected override async Task OnAfterRenderAsync(bool firstRender) 6 | { 7 | // redirect to home if already logged in 8 | if (await SessionManager.IsUserLoggedInAsync()) 9 | { 10 | NavigationManager.NavigateTo("/laps"); 11 | } 12 | else 13 | { 14 | NavigationManager.NavigateTo("/login"); 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Pages/LAPS.razor: -------------------------------------------------------------------------------- 1 | @page "/laps" 2 | @inject ISessionManagerService SessionManager 3 | @inject NavigationManager NavigationManager 4 | @inject ILdapService LdapService 5 | @inject ISnackbar Snackbar 6 | @inject IDialogService Dialog 7 | 8 | 9 | 10 | @if (!Authenticated) 11 | { 12 | Access denied! 13 | } 14 | else 15 | { 16 | 19 | 20 | 21 | @($"{e.Name}") 22 | 23 | 24 | 25 | 26 | @($"{e.Name}") 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | @foreach(AdComputer computer in SelectedComputers) 40 | { 41 | 42 | 43 | 44 | 45 | @computer.Name 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | 62 | @if (!computer.FailedToRetrieveLapsDetails) 63 | { 64 | 65 | x is { Version: Enums.LAPSVersion.v1, IsCurrent: true }))> 66 | 67 | 68 | x is { Version: Enums.LAPSVersion.v2, IsCurrent: true }))> 69 | 70 | 71 | x is { Version: Enums.LAPSVersion.v2, IsCurrent: false }))> 72 | 73 | 74 | 75 | Account 76 | Password 77 | Date set 78 | 79 | 80 | 81 | @foreach (LapsInformation entry in computer.LapsInformations!.Where(x => x is { IsCurrent: false, Version: Enums.LAPSVersion.v2 })) 82 | { 83 | 84 | 85 | @entry.Account 86 | 87 | 88 | @entry.Password 89 | 90 | 91 | @entry.PasswordSetDate 92 | 93 | 94 | } 95 | 96 | 97 | 98 | 99 | } 100 | else 101 | { 102 | 103 | No permission to retrieve LAPS Password or no LAPS Password set! 104 | 105 | } 106 | 107 |
108 |
109 |
110 |
111 | } 112 | 113 |
114 | } 115 |
116 | -------------------------------------------------------------------------------- /src/Pages/LAPS.razor.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Dialogs; 2 | using LAPS_WebUI.Enums; 3 | using LAPS_WebUI.Models; 4 | using MudBlazor; 5 | using Serilog; 6 | 7 | namespace LAPS_WebUI.Pages 8 | { 9 | public partial class LAPS : IDisposable 10 | { 11 | private readonly Dictionary _mudTabsDict = []; 12 | private MudAutocomplete? _autoCompleteSearchBox; 13 | private bool _disposedValue; 14 | private bool Authenticated { get; set; } = true; 15 | private LdapForNet.LdapCredential? LdapCredential { get; set; } 16 | private List SelectedComputers { get; set; } = []; 17 | private string? DomainName { get; set; } 18 | protected override async Task OnAfterRenderAsync(bool firstRender) 19 | { 20 | Authenticated = await SessionManager.IsUserLoggedInAsync(); 21 | 22 | if (!Authenticated) 23 | { 24 | NavigationManager.NavigateTo("/login"); 25 | } 26 | 27 | if (firstRender && Authenticated) 28 | { 29 | LdapCredential = await SessionManager.GetLdapCredentialsAsync(); 30 | DomainName = await SessionManager.GetDomainAsync(); 31 | } 32 | 33 | await InvokeAsync(StateHasChanged); 34 | } 35 | 36 | private async Task OnSelectedItemChangedAsync(AdComputer value) 37 | { 38 | if (value != null && _autoCompleteSearchBox != null && !string.IsNullOrEmpty(value.Name) && !SelectedComputers.Exists(x => x.Name == value.Name)) 39 | { 40 | await _autoCompleteSearchBox.ClearAsync(); 41 | _mudTabsDict.Add(value.Name, null); 42 | await FetchComputerDetailsAsync(value.DistinguishedName, value.Name); 43 | } 44 | } 45 | 46 | private async Task ClearLapsPassword(AdComputer computer) 47 | { 48 | try 49 | { 50 | 51 | _mudTabsDict.TryGetValue(computer.Name, out MudTabs? tab); 52 | 53 | if (tab != null && computer.LapsInformations != null) 54 | { 55 | LAPSVersion version = tab.ActivePanel.ID.ToString() switch 56 | { 57 | "v1" => LAPSVersion.v1, 58 | "v2" => LAPSVersion.v2, 59 | _ => LAPSVersion.v1 60 | }; 61 | 62 | DialogParameters parameters = new DialogParameters { ["ContentText"] = $"Clear LAPS {version} Password on Computer '{computer.Name}' ?{Environment.NewLine}You have to invoke gpupdate /force on computer '{computer.Name}' in order so set a new LAPS password", ["CancelButtonText"] = "Cancel", ["ConfirmButtonText"] = "Clear", ["ConfirmButtonColor"] = Color.Error }; 63 | IDialogReference dialog = await Dialog.ShowAsync("Clear LAPS Password", parameters,new DialogOptions() { NoHeader = true }); 64 | DialogResult? result = await dialog.Result; 65 | 66 | if(result is { Canceled: false }) 67 | { 68 | computer.LapsInformations.Clear(); 69 | await InvokeAsync(StateHasChanged); 70 | await LdapService.ClearLapsPassword(DomainName ?? await SessionManager.GetDomainAsync(), LdapCredential ?? await SessionManager.GetLdapCredentialsAsync(), computer.DistinguishedName, version); 71 | Snackbar.Add($"LAPS {version} Password for computer '{computer.Name}' successfully cleared! - Please invoke gpupdate on {computer.Name} to set a new LAPS Password", Severity.Success); 72 | } 73 | } 74 | } 75 | catch (Exception ex) 76 | { 77 | Log.Error("{ErrorMessage}", ex.Message); 78 | Snackbar.Add($"Failed to reset LAPS password for computer {computer.Name}", Severity.Error); 79 | } 80 | finally 81 | { 82 | await RefreshComputerDetailsAsync(computer,true); 83 | } 84 | } 85 | 86 | private async Task RefreshComputerDetailsAsync(AdComputer computer, bool supressNotify = false) 87 | { 88 | 89 | AdComputer? placeHolder = null; 90 | List backup = []; 91 | 92 | try 93 | { 94 | placeHolder = SelectedComputers.Single(x => x.Name == computer.Name); 95 | 96 | if (placeHolder.LapsInformations != null) 97 | { 98 | backup.AddRange(placeHolder.LapsInformations); 99 | } 100 | 101 | placeHolder.LapsInformations = null; 102 | await InvokeAsync(StateHasChanged); 103 | 104 | AdComputer? tmp = await LdapService.GetAdComputerAsync(DomainName ?? await SessionManager.GetDomainAsync(), LdapCredential ?? await SessionManager.GetLdapCredentialsAsync(), computer.DistinguishedName); 105 | 106 | if (tmp != null) 107 | { 108 | placeHolder.LapsInformations = tmp.LapsInformations; 109 | 110 | if (!supressNotify) 111 | { 112 | Snackbar.Add($"LAPS data for computer {computer.Name} successfully refreshed!", Severity.Success); 113 | } 114 | 115 | } 116 | } 117 | catch (Exception ex) 118 | { 119 | Log.Error("{ErrorMessage}", ex.Message); 120 | 121 | if (placeHolder != null) 122 | { 123 | placeHolder.LapsInformations = backup; 124 | } 125 | 126 | if (!supressNotify) 127 | { 128 | Snackbar.Add($"Failed to refresh LAPS data for computer {computer.Name}", Severity.Error); 129 | } 130 | 131 | } 132 | finally 133 | { 134 | await InvokeAsync(StateHasChanged); 135 | } 136 | } 137 | 138 | private async Task FetchComputerDetailsAsync(string distinguishedName, string computerName) 139 | { 140 | try 141 | { 142 | AdComputer placeHolder = new AdComputer(distinguishedName, computerName); 143 | SelectedComputers.Add(placeHolder); 144 | await InvokeAsync(StateHasChanged); 145 | 146 | AdComputer? adComputerObject = await LdapService.GetAdComputerAsync(DomainName ?? await SessionManager.GetDomainAsync(), LdapCredential ?? await SessionManager.GetLdapCredentialsAsync(), distinguishedName); 147 | AdComputer? selectedComputer = SelectedComputers.SingleOrDefault(x => x.Name == computerName); 148 | 149 | if (adComputerObject != null && selectedComputer != null) 150 | { 151 | selectedComputer.LapsInformations = adComputerObject.LapsInformations; 152 | selectedComputer.FailedToRetrieveLapsDetails = adComputerObject.FailedToRetrieveLapsDetails; 153 | 154 | await InvokeAsync(StateHasChanged); 155 | _mudTabsDict.TryGetValue(computerName, out MudTabs? tab); 156 | 157 | if (!selectedComputer.FailedToRetrieveLapsDetails && tab != null) 158 | { 159 | await InvokeAsync(StateHasChanged); 160 | tab.ActivatePanel(tab.Panels.First(x => !x.Disabled)); 161 | } 162 | 163 | } 164 | } 165 | catch (Exception ex) 166 | { 167 | Log.Error("{ErrorMessage}", ex.Message); 168 | SelectedComputers.RemoveAll(x => x.Name == computerName); 169 | Snackbar.Add($"Failed to fetch LAPS data for computer {computerName}\nError: {ex.Message}", Severity.Error); 170 | } 171 | } 172 | 173 | private void RemoveComputerCard(string computerName) 174 | { 175 | _mudTabsDict.Remove(computerName); 176 | SelectedComputers.RemoveAll(x => x.Name == computerName); 177 | } 178 | 179 | private async Task> SearchAsync(string value,CancellationToken token) 180 | { 181 | List searchResult = []; 182 | if (string.IsNullOrEmpty(value)) 183 | { 184 | return []; 185 | } 186 | List tmp = await LdapService.SearchAdComputersAsync(DomainName ?? await SessionManager.GetDomainAsync(), LdapCredential ?? await SessionManager.GetLdapCredentialsAsync(), value); 187 | searchResult.AddRange(tmp); 188 | return searchResult; 189 | 190 | } 191 | 192 | protected virtual void Dispose(bool disposing) 193 | { 194 | // check if already disposed 195 | if (_disposedValue) return; 196 | if (disposing) 197 | { 198 | // free managed objects here 199 | SelectedComputers.Clear(); 200 | } 201 | 202 | // set the bool value to true 203 | _disposedValue = true; 204 | } 205 | 206 | public void Dispose() 207 | { 208 | Dispose(disposing: true); 209 | GC.SuppressFinalize(this); 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Pages/Login.razor: -------------------------------------------------------------------------------- 1 | @page "/login" 2 | @inject NavigationManager NavigationManager 3 | @inject ISessionManagerService SessionManager 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | @foreach(string domain in _domains) 16 | { 17 | 18 | } 19 | 20 | 21 | 22 | 23 | Login 24 | 25 | 26 | Logging in... 27 | 28 | 29 | 30 | 31 | 32 | @if (!string.IsNullOrEmpty(_errorMessage)) 33 | { 34 | @_errorMessage 35 | } 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Pages/Login.razor.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Models; 2 | using Microsoft.AspNetCore.Components.Forms; 3 | 4 | namespace LAPS_WebUI.Pages 5 | { 6 | public partial class Login 7 | { 8 | private readonly UserLoginRequest _loginRequest = new(); 9 | private bool _processing; 10 | private string _errorMessage = string.Empty; 11 | private List _domains = []; 12 | 13 | protected override async Task OnInitializedAsync() 14 | { 15 | _domains = await SessionManager.GetDomainsAsync(); 16 | 17 | if (_domains.Count > 0 ) 18 | { 19 | _loginRequest.DomainName = _domains[0]; 20 | } 21 | } 22 | 23 | private async Task OnValidSubmitAsync(EditContext context) 24 | { 25 | _errorMessage = string.Empty; 26 | _processing = true; 27 | try 28 | { 29 | if (await SessionManager.LoginAsync(_loginRequest.DomainName ?? string.Empty, _loginRequest.Username ?? string.Empty, _loginRequest.Password ?? string.Empty)) 30 | { 31 | NavigationManager.NavigateTo("/laps"); 32 | } 33 | else 34 | { 35 | throw new Exception("Login failed!"); 36 | } 37 | } 38 | catch (Exception ex) 39 | { 40 | _errorMessage = ex.Message; 41 | } 42 | finally 43 | { 44 | _processing = false; 45 | await InvokeAsync(StateHasChanged); 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Pages/Logout.razor: -------------------------------------------------------------------------------- 1 | @page "/logout" 2 | @inject ISessionManagerService SessionManager 3 | @inject NavigationManager NavigationManager 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Logging you out... 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Pages/Logout.razor.cs: -------------------------------------------------------------------------------- 1 | namespace LAPS_WebUI.Pages 2 | { 3 | public partial class Logout 4 | { 5 | protected override async Task OnAfterRenderAsync(bool firstRender) 6 | { 7 | if (await SessionManager.IsUserLoggedInAsync()) 8 | { 9 | await SessionManager.LogoutAsync(); 10 | } 11 | 12 | await Task.Delay(500); 13 | NavigationManager.NavigateTo("/"); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Pages/_Host.cshtml: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @namespace LAPS_WebUI.Pages 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | @{ 5 | Layout = "_Layout"; 6 | } 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Pages/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Web 2 | @namespace LAPS_WebUI.Pages 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | @RenderBody() 20 | 21 |
22 | 23 | An error has occurred. This application may no longer respond until reloaded. 24 | 25 | 26 | An unhandled exception has occurred. See browser dev tools for details. 27 | 28 | Reload 29 | 🗙 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Program.cs: -------------------------------------------------------------------------------- 1 | using Blazored.SessionStorage; 2 | using CurrieTechnologies.Razor.Clipboard; 3 | using LAPS_WebUI.Interfaces; 4 | using LAPS_WebUI.Models; 5 | using LAPS_WebUI.Services; 6 | using MudBlazor; 7 | using MudBlazor.Services; 8 | using Serilog; 9 | using Serilog.Events; 10 | 11 | WebApplicationBuilder builder = WebApplication.CreateBuilder(args); 12 | 13 | builder.Host.UseSerilog((ctx, lc) => lc 14 | .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) 15 | .Enrich.FromLogContext() 16 | .WriteTo.Console() 17 | .WriteTo.Console()); 18 | 19 | // Add services to the container. 20 | builder.Services.AddRazorPages(); 21 | builder.Services.AddServerSideBlazor(); 22 | builder.Services.AddMudServices(config => 23 | { 24 | config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomCenter; 25 | config.SnackbarConfiguration.ShowCloseIcon = true; 26 | config.SnackbarConfiguration.NewestOnTop = true; 27 | config.SnackbarConfiguration.SnackbarVariant = Variant.Filled; 28 | }); 29 | builder.Services.AddBlazoredSessionStorage(); 30 | builder.Services.AddClipboard(); 31 | builder.Services.AddDataProtection(); 32 | builder.Services.AddHealthChecks(); 33 | 34 | builder.Services.Configure>(builder.Configuration.GetSection("Domains")); 35 | builder.Services.AddScoped(); 36 | builder.Services.AddScoped(); 37 | builder.Services.AddSingleton(); 38 | 39 | WebApplication app = builder.Build(); 40 | 41 | // Configure the HTTP request pipeline. 42 | if (!app.Environment.IsDevelopment()) 43 | { 44 | app.UseExceptionHandler("/Error"); 45 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 46 | app.UseHsts(); 47 | } 48 | 49 | app.UseHttpsRedirection(); 50 | app.UseStaticFiles(); 51 | app.UseRouting(); 52 | 53 | app.MapBlazorHub(); 54 | app.MapFallbackToPage("/_Host"); 55 | app.MapHealthChecks("/healthz"); 56 | 57 | app.UseSerilogRequestLogging(); 58 | app.Run(); 59 | -------------------------------------------------------------------------------- /src/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "IIS Express": { 4 | "commandName": "IISExpress", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | } 9 | }, 10 | "Docker": { 11 | "commandName": "Docker", 12 | "launchBrowser": true, 13 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 14 | "publishAllPorts": true, 15 | "useSSL": true 16 | }, 17 | "LAPS-WebUI - K-SYS": { 18 | "commandName": "Project", 19 | "launchBrowser": true, 20 | "environmentVariables": { 21 | "ASPNETCORE_ENVIRONMENT": "Development", 22 | "Domains__0__Name": "prime.k-sys.io", 23 | "Domains__0__Ldap__Server": "ldap.prime.k-sys.io", 24 | "Domains__0__Ldap__Port": "636", 25 | "Domains__0__Ldap__UseSSL": "true", 26 | "Domains__0__Ldap__SearchBase": "OU=Klett IT GmbH,DC=prime,DC=k-sys,DC=io", 27 | " Domains__0__Ldap__TrustAllCertificates": "true" 28 | }, 29 | "applicationUrl": "https://localhost:7213;http://localhost:5213", 30 | "dotnetRunMessages": true 31 | } 32 | }, 33 | "iisSettings": { 34 | "windowsAuthentication": false, 35 | "anonymousAuthentication": true, 36 | "iisExpress": { 37 | "applicationUrl": "http://localhost:46848", 38 | "sslPort": 44370 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/Services/CryptService.cs: -------------------------------------------------------------------------------- 1 | using LAPS_WebUI.Interfaces; 2 | using Microsoft.AspNetCore.DataProtection; 3 | 4 | namespace LAPS_WebUI.Services 5 | { 6 | public class CryptService : ICryptService 7 | { 8 | private readonly IDataProtectionProvider _dataProtectionProvider; 9 | private readonly string _keyString; 10 | public CryptService(IDataProtectionProvider dataProtectionProvider) 11 | { 12 | _dataProtectionProvider = dataProtectionProvider; 13 | _keyString = Guid.NewGuid().ToString().Replace("-", ""); 14 | } 15 | public string DecryptString(string cipherText) 16 | { 17 | IDataProtector protector = _dataProtectionProvider.CreateProtector(_keyString); 18 | return protector.Unprotect(cipherText); 19 | } 20 | 21 | public string EncryptString(string text) 22 | { 23 | IDataProtector protector = _dataProtectionProvider.CreateProtector(_keyString); 24 | return protector.Protect(text); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Services/LDAPService.cs: -------------------------------------------------------------------------------- 1 | using CliWrap; 2 | using LAPS_WebUI.Enums; 3 | using LAPS_WebUI.Interfaces; 4 | using LAPS_WebUI.Models; 5 | using LdapForNet; 6 | using Microsoft.Extensions.Options; 7 | using Serilog; 8 | using System.Runtime.InteropServices; 9 | using System.Text; 10 | using System.Text.Json; 11 | using static LdapForNet.Native.Native; 12 | 13 | namespace LAPS_WebUI.Services 14 | { 15 | public class LdapService : ILdapService 16 | { 17 | private readonly IOptions> _domains; 18 | public LdapService(IOptions> domains) 19 | { 20 | _domains = domains; 21 | 22 | if (_domains.Value.Count == 0) 23 | { 24 | Log.Error("No Domains configured! Please check your configuration!"); 25 | } 26 | } 27 | 28 | public async Task> GetDomainsAsync() 29 | { 30 | return await Task.FromResult(_domains.Value); 31 | } 32 | 33 | public async Task CreateBindAsync(string domainName, string username, string password) 34 | { 35 | LdapConnection ldapConnection = new(); 36 | 37 | try 38 | { 39 | Domain domain = _domains.Value.Single(x => x.Name == domainName); 40 | 41 | ldapConnection.Connect(domain.Ldap.Server, domain.Ldap.Port, domain.Ldap.UseSsl ? LdapSchema.LDAPS : LdapSchema.LDAP); 42 | 43 | if (domain.Ldap.TrustAllCertificates) 44 | { 45 | ldapConnection.TrustAllCertificates(); 46 | } 47 | 48 | await ldapConnection.BindAsync(domain.Ldap.AuthMechanism,username, password); 49 | 50 | ldapConnection.SetOption(LdapOption.LDAP_OPT_REFERRALS, IntPtr.Zero); 51 | 52 | } 53 | catch (Exception ex) 54 | { 55 | Log.Error("{ErrorMessage}",ex.Message); 56 | return null; 57 | } 58 | 59 | return ldapConnection; 60 | } 61 | 62 | public async Task TestCredentialsAsync(string domainName, string username, string password) 63 | { 64 | using LdapConnection? connection = await CreateBindAsync(domainName, username, password); 65 | return connection != null; 66 | } 67 | 68 | public async Task TestCredentialsAsync(string domainName, LdapCredential ldapCredential) 69 | { 70 | using LdapConnection? connection = await CreateBindAsync(domainName, ldapCredential.UserName, ldapCredential.Password); 71 | return connection != null; 72 | } 73 | 74 | public async Task ClearLapsPassword(string domainName, LdapCredential ldapCredential, string distinguishedName, LAPSVersion version) 75 | { 76 | 77 | if (ldapCredential is null) 78 | { 79 | throw new Exception("Failed to get LDAP Credentials"); 80 | } 81 | using LdapConnection ldapConnection = await CreateBindAsync(domainName, ldapCredential.UserName, ldapCredential.Password) ?? throw new Exception("LDAP bind failed!"); 82 | 83 | string attribute = version switch 84 | { 85 | LAPSVersion.v1 => "ms-Mcs-AdmPwdExpirationTime", 86 | LAPSVersion.v2 => "msLAPS-PasswordExpirationTime", 87 | _ => string.Empty 88 | }; 89 | 90 | DirectoryModificationAttribute resetRequest = new DirectoryModificationAttribute 91 | { 92 | LdapModOperation = LdapModOperation.LDAP_MOD_REPLACE, 93 | Name = attribute 94 | }; 95 | 96 | resetRequest.Add(DateTime.Now.ToFileTimeUtc().ToString()); 97 | 98 | ModifyResponse? response = (ModifyResponse)await ldapConnection.SendRequestAsync(new ModifyRequest(distinguishedName, resetRequest)); 99 | 100 | return response.ResultCode == ResultCode.Success; 101 | } 102 | 103 | private static string EscapeLdapSearchFilter(string searchFilter) 104 | { 105 | StringBuilder escape = new StringBuilder(); 106 | foreach (char current in searchFilter) 107 | { 108 | switch (current) 109 | { 110 | case '\\': 111 | escape.Append(@"\5c"); 112 | break; 113 | case '*': 114 | escape.Append(@"\2a"); 115 | break; 116 | case '(': 117 | escape.Append(@"\28"); 118 | break; 119 | case ')': 120 | escape.Append(@"\29"); 121 | break; 122 | case '\u0000': 123 | escape.Append(@"\00"); 124 | break; 125 | case '/': 126 | escape.Append(@"\2f"); 127 | break; 128 | default: 129 | escape.Append(current); 130 | break; 131 | } 132 | } 133 | 134 | return escape.ToString(); 135 | } 136 | 137 | public async Task GetAdComputerAsync(string domainName, LdapCredential ldapCredential, string distinguishedName) 138 | { 139 | AdComputer? adComputer; 140 | Domain domain = _domains.Value.SingleOrDefault(x => x.Name == domainName) ?? throw new Exception($"No configured domain found with name {domainName}"); 141 | 142 | if (ldapCredential is null) 143 | { 144 | throw new Exception("Failed to get LDAP Credentials"); 145 | } 146 | 147 | using LdapConnection? ldapConnection = await CreateBindAsync(domainName, ldapCredential.UserName, ldapCredential.Password); 148 | if (ldapConnection is null) 149 | { 150 | throw new Exception("LDAP bind failed!"); 151 | } 152 | 153 | string? defaultNamingContext = domain.Ldap.SearchBase; 154 | 155 | string ldapFilter = $"(&(objectCategory=computer)(distinguishedName={distinguishedName}))"; 156 | 157 | LdapEntry? ldapSearchResult = (await ldapConnection.SearchAsync(defaultNamingContext, ldapFilter)).SingleOrDefault(); 158 | 159 | if (ldapSearchResult != null) 160 | { 161 | adComputer = new AdComputer(ldapSearchResult.Dn, ldapSearchResult.DirectoryAttributes["cn"].GetValues().First()) 162 | { 163 | LapsInformations = [] 164 | }; 165 | 166 | #region "Try LAPS v1" 167 | 168 | if (ldapSearchResult.DirectoryAttributes.Any(x => x.Name == "ms-Mcs-AdmPwd") && (domain.Laps.ForceVersion == LAPSVersion.All || domain.Laps.ForceVersion == LAPSVersion.v1)) 169 | { 170 | LapsInformation lapsInformationEntry = new() 171 | { 172 | ComputerName = adComputer.Name, 173 | Version = LAPSVersion.v1, 174 | Account = null, 175 | Password = ldapSearchResult.DirectoryAttributes["ms-Mcs-AdmPwd"].GetValues().First(), 176 | PasswordExpireDate = DateTime.FromFileTimeUtc(Convert.ToInt64(ldapSearchResult.DirectoryAttributes["ms-Mcs-AdmPwdExpirationTime"].GetValues().First())).ToLocalTime(), 177 | IsCurrent = true, 178 | PasswordSetDate = null 179 | }; 180 | 181 | adComputer.LapsInformations.Add(lapsInformationEntry); 182 | } 183 | 184 | #endregion 185 | 186 | #region "Try LAPS v2" 187 | 188 | string fieldName = (domain.Laps.EncryptionDisabled ? "msLAPS-Password" : "msLAPS-EncryptedPassword"); 189 | 190 | if (ldapSearchResult.DirectoryAttributes.Any(x => x.Name == fieldName) && (domain.Laps.ForceVersion == LAPSVersion.All || domain.Laps.ForceVersion == LAPSVersion.v2)) 191 | { 192 | MsLapsPayload? msLapsPayload; 193 | string ldapValue; 194 | 195 | if (domain.Laps.EncryptionDisabled) 196 | { 197 | ldapValue = ldapSearchResult.DirectoryAttributes["msLAPS-Password"].GetValues().First(); 198 | } 199 | else 200 | { 201 | byte[] encryptedPass = ldapSearchResult.DirectoryAttributes["msLAPS-EncryptedPassword"].GetValues().First().Skip(16).ToArray(); 202 | ldapValue = await DecryptLapsPayload(encryptedPass, ldapCredential); 203 | } 204 | 205 | msLapsPayload = JsonSerializer.Deserialize(ldapValue) ?? throw new Exception("Failed to parse LAPS Password"); 206 | 207 | LapsInformation lapsInformationEntry = new() 208 | { 209 | ComputerName = adComputer.Name, 210 | Version = LAPSVersion.v2, 211 | Account = msLapsPayload.ManagedAccountName, 212 | Password = msLapsPayload.Password, 213 | WasEncrypted = !domain.Laps.EncryptionDisabled, 214 | PasswordExpireDate = DateTime.FromFileTimeUtc(Convert.ToInt64(ldapSearchResult.DirectoryAttributes["msLAPS-PasswordExpirationTime"].GetValues().First())).ToLocalTime(), 215 | IsCurrent = true, 216 | PasswordSetDate = DateTime.FromFileTimeUtc(Int64.Parse(msLapsPayload.PasswordUpdateTime!, System.Globalization.NumberStyles.HexNumber)).ToLocalTime() 217 | 218 | }; 219 | 220 | adComputer.LapsInformations.Add(lapsInformationEntry); 221 | 222 | if (ldapSearchResult.DirectoryAttributes.Any(x => x.Name == "msLAPS-EncryptedPasswordHistory")) 223 | { 224 | 225 | foreach (byte[]? historyEntry in ldapSearchResult.DirectoryAttributes["msLAPS-EncryptedPasswordHistory"].GetValues()) 226 | { 227 | byte[] historicEncryptedPass = historyEntry.Skip(16).ToArray(); 228 | string historicLdapValue = await DecryptLapsPayload(historicEncryptedPass, ldapCredential); 229 | MsLapsPayload? historicMsLapsPayload = JsonSerializer.Deserialize(historicLdapValue); 230 | 231 | if (historicMsLapsPayload != null) 232 | { 233 | LapsInformation historicLapsInformationEntry = new() 234 | { 235 | ComputerName = adComputer.Name, 236 | Version = LAPSVersion.v2, 237 | Account = historicMsLapsPayload.ManagedAccountName, 238 | Password = historicMsLapsPayload.Password, 239 | PasswordExpireDate = null, 240 | PasswordSetDate = DateTime.FromFileTimeUtc(Int64.Parse(historicMsLapsPayload.PasswordUpdateTime!, System.Globalization.NumberStyles.HexNumber)).ToLocalTime() 241 | }; 242 | 243 | adComputer.LapsInformations.Add(historicLapsInformationEntry); 244 | } 245 | else 246 | { 247 | Log.Warning("Failed to decrypt LAPS History entry"); 248 | } 249 | } 250 | } 251 | } 252 | 253 | #endregion 254 | 255 | if (adComputer.LapsInformations is null || adComputer.LapsInformations.Count == 0) 256 | { 257 | adComputer.FailedToRetrieveLapsDetails = true; 258 | } 259 | else 260 | { 261 | adComputer.LapsInformations = [.. adComputer.LapsInformations.OrderBy(x => x.PasswordExpireDate)]; 262 | } 263 | 264 | } 265 | else 266 | { 267 | throw new Exception($"AD Computer with DN '{distinguishedName}' could not be found"); 268 | } 269 | 270 | return adComputer; 271 | } 272 | 273 | private static async Task DecryptLapsPayload(byte[] value, LdapCredential ldapCredential) 274 | { 275 | 276 | StringBuilder pythonScriptResult = new(); 277 | string pythonDecryptScriptPath = Path.Combine(Path.GetDirectoryName(AppContext.BaseDirectory)!, "scripts", "DecryptEncryptedLAPSPassword.py"); 278 | 279 | string pythonBin = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python" : "python3"; 280 | 281 | try 282 | { 283 | 284 | Command pythonCmd = Cli.Wrap(pythonBin) 285 | .WithArguments($"\"{pythonDecryptScriptPath}\" --user \"{ldapCredential.UserName}\" --password \"{ldapCredential.Password}\" --data \"{Convert.ToBase64String(value)}\"") 286 | .WithStandardOutputPipe(PipeTarget.ToStringBuilder(pythonScriptResult)); 287 | 288 | await pythonCmd.ExecuteAsync(); 289 | 290 | if (pythonDecryptScriptPath is null || pythonDecryptScriptPath.Length == 0) 291 | { 292 | throw new Exception("Failed to decrypt laps password!"); 293 | } 294 | 295 | string ldapValue = pythonScriptResult.ToString().Trim(); 296 | ldapValue = ldapValue.Remove(ldapValue.LastIndexOf('}') + 1); 297 | 298 | return ldapValue; 299 | } 300 | catch (Exception ex) 301 | { 302 | Log.Error("Decrypt LAPS Password failed => {ErrorMessage}", ex.Message); 303 | throw new ArgumentException("Failed to decrypt LAPSv2 Password"); 304 | } 305 | 306 | } 307 | 308 | public async Task> SearchAdComputersAsync(string domainName, LdapCredential ldapCredential, string query) 309 | { 310 | List result = []; 311 | Domain domain = _domains.Value.SingleOrDefault(x => x.Name == domainName) ?? throw new Exception($"No configured domain found with name {domainName}"); 312 | 313 | if (ldapCredential is null) 314 | { 315 | throw new Exception("Failed to get LDAP Credentials"); 316 | } 317 | 318 | using LdapConnection? ldapConnection = await CreateBindAsync(domainName, ldapCredential.UserName, ldapCredential.Password); 319 | string filter = $"(&(objectCategory=computer)(name={query}{(query.EndsWith('*') ? string.Empty : '*')}))"; 320 | string[] propertiesToLoad = new string[] { "cn", "distinguishedName" }; 321 | string? defaultNamingContext = domain.Ldap.SearchBase; 322 | 323 | try 324 | { 325 | if (ldapConnection is null) 326 | { 327 | throw new Exception("LDAP Bind failed!"); 328 | } 329 | 330 | IList? ldapSearchResults = await ldapConnection.SearchAsync(defaultNamingContext, filter, propertiesToLoad); 331 | 332 | result.AddRange(ldapSearchResults.Select(o => new AdComputer(EscapeLdapSearchFilter(o.Dn), o.DirectoryAttributes["cn"].GetValues().First())).ToList()); 333 | } 334 | catch (Exception ex) 335 | { 336 | Log.Error("{ErrorMessage}", ex.Message); 337 | } 338 | 339 | return result; 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/Services/SessionManagerService.cs: -------------------------------------------------------------------------------- 1 | using Blazored.SessionStorage; 2 | using LAPS_WebUI.Interfaces; 3 | using LdapForNet; 4 | 5 | namespace LAPS_WebUI.Services 6 | { 7 | public class SessionManagerService( 8 | ISessionStorageService sessionStorageService, 9 | ILdapService ldapService, 10 | ICryptService cryptService) 11 | : ISessionManagerService 12 | { 13 | public async Task> GetDomainsAsync() 14 | { 15 | return (await ldapService.GetDomainsAsync()).Select(x => x.Name).ToList(); 16 | } 17 | 18 | public async Task GetDomainAsync() 19 | { 20 | return await sessionStorageService.GetItemAsync("domainName"); 21 | } 22 | 23 | public async Task GetLdapCredentialsAsync() 24 | { 25 | LdapCredential? encryptedCreds = await sessionStorageService.GetItemAsync("ldapCredentials"); 26 | 27 | encryptedCreds.UserName = cryptService.DecryptString(encryptedCreds.UserName); 28 | encryptedCreds.Password = cryptService.DecryptString(encryptedCreds.Password); 29 | 30 | return encryptedCreds; 31 | } 32 | 33 | public async Task IsUserLoggedInAsync() 34 | { 35 | return await sessionStorageService.GetItemAsync("loggedIn"); 36 | } 37 | 38 | public async Task LoginAsync(string domainName, string username, string password) 39 | { 40 | bool bindResult = await ldapService.TestCredentialsAsync(domainName,username, password); 41 | 42 | if (!bindResult) 43 | { 44 | return false; 45 | } 46 | else 47 | { 48 | await sessionStorageService.SetItemAsync("loggedIn", bindResult); 49 | await sessionStorageService.SetItemAsync("domainName", domainName); 50 | await sessionStorageService.SetItemAsync("ldapCredentials", new LdapCredential() { UserName = cryptService.EncryptString(username), Password = cryptService.EncryptString(password) }); 51 | 52 | return true; 53 | } 54 | } 55 | public async Task LogoutAsync() 56 | { 57 | await sessionStorageService.ClearAsync(); 58 | return true; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @inject NavigationManager NavigationManager 3 | @inject ISessionManagerService sessionManager 4 | 5 | 6 | 7 | 8 | 9 | 10 | LAPS-WebUI 11 | 12 | 13 | 14 | LAPS WebUI 15 | 16 | 17 | 18 | 19 | @if (IsUserLoggedIn) 20 | { 21 | 22 | 23 | 24 | 25 | } 26 | 27 | 28 | @Body 29 | 30 | -------------------------------------------------------------------------------- /src/Shared/MainLayout.razor.cs: -------------------------------------------------------------------------------- 1 | using MudBlazor.Utilities; 2 | using MudBlazor; 3 | 4 | namespace LAPS_WebUI.Shared 5 | { 6 | public partial class MainLayout 7 | { 8 | 9 | private bool _isDarkMode; 10 | private MudThemeProvider _mudThemeProvider = new(); 11 | private bool IsUserLoggedIn { get; set; } = false; 12 | private readonly MudTheme _myCustomTheme = new() 13 | { 14 | PaletteLight = new PaletteLight() 15 | { 16 | Primary = new MudColor("#455FAC"), 17 | Secondary = new MudColor("#CE3C3C"), 18 | AppbarBackground = new MudColor("#455FAC"), 19 | DrawerText = new MudColor("#D7D7D9"), 20 | DrawerIcon = new MudColor("#D7D7D9"), 21 | DrawerBackground = new MudColor("#3C3D3F") 22 | } 23 | }; 24 | 25 | private void ToggleDarkMode() 26 | { 27 | _isDarkMode = !_isDarkMode; 28 | } 29 | 30 | private void Logout() 31 | { 32 | NavigationManager.NavigateTo("/logout"); 33 | } 34 | 35 | protected override async Task OnAfterRenderAsync(bool firstRender) 36 | { 37 | IsUserLoggedIn = await sessionManager.IsUserLoggedInAsync(); 38 | 39 | if (firstRender) 40 | { 41 | _isDarkMode = await _mudThemeProvider.GetSystemPreference(); 42 | } 43 | 44 | await InvokeAsync(StateHasChanged); 45 | await base.OnAfterRenderAsync(firstRender); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | } 28 | 29 | .top-row a:first-child { 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | } 33 | 34 | @media (max-width: 640.98px) { 35 | .top-row:not(.auth) { 36 | display: none; 37 | } 38 | 39 | .top-row.auth { 40 | justify-content: space-between; 41 | } 42 | 43 | .top-row a, .top-row .btn-link { 44 | margin-left: 0; 45 | } 46 | } 47 | 48 | @media (min-width: 641px) { 49 | .page { 50 | flex-direction: row; 51 | } 52 | 53 | .sidebar { 54 | width: 250px; 55 | height: 100vh; 56 | position: sticky; 57 | top: 0; 58 | } 59 | 60 | .top-row { 61 | position: sticky; 62 | top: 0; 63 | z-index: 1; 64 | } 65 | 66 | .top-row, article { 67 | padding-left: 2rem !important; 68 | padding-right: 1.5rem !important; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using Microsoft.AspNetCore.Authorization 3 | @using Microsoft.AspNetCore.Components.Authorization 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using Microsoft.AspNetCore.Components.Web.Virtualization 8 | @using Microsoft.JSInterop 9 | @using MudBlazor 10 | @using MudExtensions 11 | @using LAPS_WebUI 12 | @using LAPS_WebUI.Shared 13 | @using LAPS_WebUI.Interfaces 14 | @using LAPS_WebUI.Services 15 | @using LAPS_WebUI.Models 16 | @using LAPS_WebUI.Components 17 | -------------------------------------------------------------------------------- /src/appsettings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "Domains": [ 10 | { 11 | "Name": "example.com", 12 | "Ldap": { 13 | "Server": "ldap.example.com", 14 | "Port": 636, 15 | "UseSSL": true, 16 | "TrustAllCertificates": true, 17 | "SearchBase": "OU=Clients,DC=example,DC=com" 18 | }, 19 | "Laps": { 20 | "ForceVersion": "All", // Allowed Values: All, v1, v2 | Default: All (v1 & v2) 21 | "EncryptionDisabled": false // Allowed Values: true, false | Default: false 22 | } 23 | }, 24 | { 25 | "Name": "contoso.com", 26 | "Ldap": { 27 | "Server": "ldap.contoso.com", 28 | "Port": 636, 29 | "UseSSL": true, 30 | "TrustAllCertificates": true, 31 | "SearchBase": "OU=Clients,DC=contoso,DC=com", 32 | "AuthMechanism": "SIMPLE" // Allowed Values: SIMPLE, GSSAPI (windows only) | Default: SIMPLE 33 | }, 34 | "Laps": { 35 | "ForceVersion": "All", // Allowed Values: All, v1, v2 | Default: All (v1 & v2) 36 | "EncryptionDisabled": false // Allowed Values: true, false | Default: false 37 | } 38 | } 39 | ], 40 | "AllowedHosts": "*" 41 | } -------------------------------------------------------------------------------- /src/scripts/DecryptEncryptedLAPSPassword.py: -------------------------------------------------------------------------------- 1 | import dpapi_ng 2 | import base64 3 | import argparse 4 | 5 | def main(): 6 | parser = argparse.ArgumentParser(prog='LAPS-WebUIPythonExt') 7 | parser.add_argument('-U','--user', default=None, required=True, dest='username') 8 | parser.add_argument('-P','--password', default=None, required=True, dest='password') 9 | parser.add_argument('-d','--data', default=None, required=True, dest='base64payload') 10 | 11 | args = parser.parse_args() 12 | encyrptedPass = base64.b64decode(args.base64payload) 13 | decryptedBlob = dpapi_ng.ncrypt_unprotect_secret(username=args.username, password=args.password, data=encyrptedPass) 14 | print(str(decryptedBlob,'utf-16')) 15 | 16 | if __name__ == '__main__': 17 | main() -------------------------------------------------------------------------------- /src/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/src/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/wwwroot/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/src/wwwroot/icon-128.png -------------------------------------------------------------------------------- /src/wwwroot/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/src/wwwroot/icon-192.png -------------------------------------------------------------------------------- /src/wwwroot/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/src/wwwroot/icon-512.png -------------------------------------------------------------------------------- /src/wwwroot/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/src/wwwroot/icon-72.png -------------------------------------------------------------------------------- /src/wwwroot/icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seji64/LAPS-WebUI/358aa7889250fce42c2bf48d6a24adcf9942376a/src/wwwroot/icon-96.png -------------------------------------------------------------------------------- /src/wwwroot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "LAPS-WebUI", 3 | "short_name": "LAPS-UI", 4 | "start_url": "./", 5 | "lang": "en-US", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#90A4AE", 9 | "icons": [ 10 | { 11 | "src": "icon-72.png", 12 | "type": "image/png", 13 | "sizes": "72x72", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "icon-96.png", 18 | "type": "image/png", 19 | "sizes": "96x96", 20 | "purpose": "any maskable" 21 | }, 22 | { 23 | "src": "icon-128.png", 24 | "type": "image/png", 25 | "sizes": "128x128", 26 | "purpose": "any maskable" 27 | }, 28 | { 29 | "src": "icon-192.png", 30 | "type": "image/png", 31 | "sizes": "192x192", 32 | "purpose": "any maskable" 33 | }, 34 | { 35 | "src": "icon-512.png", 36 | "type": "image/png", 37 | "sizes": "512x512", 38 | "purpose": "any maskable" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/wwwroot/service-worker.js: -------------------------------------------------------------------------------- 1 | // In development, always fetch from the network and do not enable offline support. 2 | // This is because caching would make development more difficult (changes would not 3 | // be reflected on the first load after each change). 4 | self.addEventListener('fetch', () => { }); 5 | -------------------------------------------------------------------------------- /src/wwwroot/service-worker.published.js: -------------------------------------------------------------------------------- 1 | // Caution! Be sure you understand the caveats before publishing an application with 2 | // offline support. See https://aka.ms/blazor-offline-considerations 3 | 4 | self.importScripts('./service-worker-assets.js'); 5 | self.addEventListener('install', event => event.waitUntil(onInstall(event))); 6 | self.addEventListener('activate', event => event.waitUntil(onActivate(event))); 7 | self.addEventListener('fetch', event => event.respondWith(onFetch(event))); 8 | 9 | const cacheNamePrefix = 'offline-cache-'; 10 | const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; 11 | const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; 12 | const offlineAssetsExclude = [ /^service-worker\.js$/ ]; 13 | 14 | async function onInstall(event) { 15 | console.info('Service worker: Install'); 16 | 17 | // Fetch and cache all matching items from the assets manifest 18 | const assetsRequests = self.assetsManifest.assets 19 | .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) 20 | .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) 21 | .map(asset => new Request(asset.url, { integrity: asset.hash })); 22 | await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); 23 | } 24 | 25 | async function onActivate(event) { 26 | console.info('Service worker: Activate'); 27 | 28 | // Delete unused caches 29 | const cacheKeys = await caches.keys(); 30 | await Promise.all(cacheKeys 31 | .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) 32 | .map(key => caches.delete(key))); 33 | } 34 | 35 | async function onFetch(event) { 36 | let cachedResponse = null; 37 | if (event.request.method === 'GET') { 38 | // For all navigation requests, try to serve index.html from cache 39 | // If you need some URLs to be server-rendered, edit the following check to exclude those URLs 40 | const shouldServeIndexHtml = event.request.mode === 'navigate'; 41 | 42 | const request = shouldServeIndexHtml ? 'index.html' : event.request; 43 | const cache = await caches.open(cacheName); 44 | cachedResponse = await cache.match(request); 45 | } 46 | 47 | return cachedResponse || fetch(event.request); 48 | } 49 | --------------------------------------------------------------------------------